Browse Source

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

newdev_shunjiawei
liwei1dao 9 months ago
parent
commit
33f87d4477
  1. 75
      android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt
  2. 14
      lib/core/bindings/initial_binding.dart
  3. 9
      lib/core/translations/language/en_us.dart
  4. 7
      lib/core/translations/language/zh_cn.dart
  5. 131
      lib/core/utils/synchrodata.dart
  6. 7
      lib/data/services/account_service.dart
  7. 29
      lib/data/services/db/sqflite_api.dart
  8. 105
      lib/data/services/meeting/meeting_task_aggregator.dart
  9. 563
      lib/data/services/meeting/meeting_task_service.dart
  10. 971
      lib/data/services/meeting/meeting_task_service2.dart
  11. 62
      lib/data/services/meeting/meeting_upload_service.dart
  12. 187
      lib/data/services/meeting/summary_task_service.dart
  13. 75
      lib/data/services/network/api.dart
  14. 6
      lib/modules/meeting/bindings/meeting_record_binding.dart
  15. 122
      lib/modules/meeting/controllers/meeting_details_controller.dart
  16. 79
      lib/modules/meeting/controllers/meeting_home_controller.dart
  17. 494
      lib/modules/meeting/controllers/meeting_record_controller.dart
  18. 48
      lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart
  19. 2
      lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart
  20. 18
      lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart
  21. 23
      lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart
  22. 11
      lib/modules/meeting/views/bottomSheet/speaker_all_bottom_sheet.dart
  23. 15
      lib/modules/meeting/views/bottomSheet/speaker_bottom_sheet.dart
  24. 44
      lib/modules/meeting/views/meeting_details_view.dart
  25. 57
      lib/modules/meeting/views/meeting_home_view.dart
  26. 583
      lib/modules/meeting/views/meeting_record_view.dart
  27. 11
      lib/modules/meeting/views/tabs/summary_tab.dart
  28. 8
      lib/modules/splash/controllers/splash_controller.dart
  29. 1
      lib/routes/app_pages.dart
  30. 35
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt
  31. 54
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt
  32. 25
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  33. 15
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt
  34. 8
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  35. 54
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift
  36. 8
      local_plugins/native_plugin/android/src/main/AndroidManifest.xml
  37. 88
      local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java
  38. 292
      local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java
  39. 31
      local_plugins/native_plugin/lib/native_plugin.dart
  40. 43
      local_plugins/native_plugin/lib/native_plugin_method_channel.dart
  41. 28
      local_plugins/native_plugin/lib/native_plugin_platform_interface.dart

75
android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt

@ -19,6 +19,11 @@ import io.flutter.plugin.common.MethodChannel // 添加这行导入
class MainActivity: FlutterActivity() {
private val TAG = "MainActivity"
private var pendingDeepLink: Intent? = null
private var pendingNavigationIntent: Intent? = null
private val EXTRA_NAVIGATE_TO = "com.saitong.voitrans.extra.NAVIGATE_TO"
private val NAVIGATE_TO_MEETING_RECORD = "meetingRecord"
private val ROUTE_MEETING_RECORD = "/meeting/record"
override fun onCreate(savedInstanceState: Bundle?) {
Log.d(TAG, "🚀 MainActivity onCreate 开始")
@ -36,6 +41,11 @@ class MainActivity: FlutterActivity() {
pendingDeepLink = intent
Log.d(TAG, "🔗 暂存深链接,等待Flutter引擎准备完成")
}
if (intent?.hasExtra(EXTRA_NAVIGATE_TO) == true) {
pendingNavigationIntent = intent
Log.d(TAG, "🧭 暂存导航意图,等待Flutter引擎准备完成")
}
// 设置日志级别,减少系统级日志
suppressSystemLogs()
@ -54,7 +64,7 @@ class MainActivity: FlutterActivity() {
Log.d(TAG, "New Intent extras: ${intent.extras}")
// 处理深链接
handleDeepLink(intent)
handleNavigationIntent(intent)
}
override fun provideFlutterEngine(context: Context): FlutterEngine? {
@ -106,17 +116,29 @@ class MainActivity: FlutterActivity() {
Handler(Looper.getMainLooper()).postDelayed({
pendingDeepLink?.let { intent ->
Log.d(TAG, "🔗 处理待处理的深链接")
handleDeepLink(intent)
handleNavigationIntent(intent)
pendingDeepLink = null
}
pendingNavigationIntent?.let { intent ->
Log.d(TAG, "🧭 处理待处理的导航意图")
handleNavigationIntent(intent)
pendingNavigationIntent = null
}
}, 1000) // 延迟1秒确保Flutter完全准备好
Log.d(TAG, "✅ configureFlutterEngine 完成")
}
// 处理深链接的统一方法
private fun handleDeepLink(intent: Intent?) {
private fun handleNavigationIntent(intent: Intent?) {
intent?.let {
val navigateTo = it.getStringExtra(EXTRA_NAVIGATE_TO)
if (navigateTo == NAVIGATE_TO_MEETING_RECORD) {
pushRouteToFlutter(ROUTE_MEETING_RECORD)
return
}
if (it.action == Intent.ACTION_VIEW && it.data != null) {
val uri = it.data
Log.d(TAG, "🔗 处理深链接: $uri")
@ -133,16 +155,7 @@ class MainActivity: FlutterActivity() {
"com.saitong.voitrans/deeplink"
)
val arguments = mapOf(
"url" to uri.toString(),
"scheme" to uri?.scheme,
"host" to uri?.host,
"path" to uri?.path,
"query" to uri?.query,
"queryParameters" to uri?.queryParameterNames?.associateWith {
uri.getQueryParameter(it)
}
)
val arguments = buildUrlArguments(uri.toString())
Log.d(TAG, "🔗 准备发送参数: $arguments")
@ -160,6 +173,41 @@ class MainActivity: FlutterActivity() {
}
}
private fun pushRouteToFlutter(route: String) {
try {
val engine = flutterEngine
if (engine == null) {
Log.w(TAG, "❌ FlutterEngine 为空,无法 pushRoute")
return
}
Handler(Looper.getMainLooper()).post {
try {
engine.navigationChannel.pushRoute(route)
} catch (e: Exception) {
Log.e(TAG, "❌ pushRoute 失败", e)
}
}
} catch (e: Exception) {
Log.e(TAG, "❌ pushRouteToFlutter 出错", e)
}
}
private fun buildUrlArguments(url: String): Map<String, Any?> {
return try {
val uri = android.net.Uri.parse(url)
mapOf(
"url" to url,
"scheme" to uri.scheme,
"host" to uri.host,
"path" to uri.path,
"query" to uri.query,
"queryParameters" to uri.queryParameterNames.associateWith { uri.getQueryParameter(it) }
)
} catch (e: Exception) {
mapOf("url" to url)
}
}
override fun onDestroy() {
super.onDestroy()
}
@ -242,4 +290,3 @@ class MainActivity: FlutterActivity() {
}
}
}

14
lib/core/bindings/initial_binding.dart

@ -1,4 +1,4 @@
import '../../data/services/meeting/summary_task_service.dart';
import '../../data/services/meeting/meeting_task_service.dart';
import '/data/services/pay_service.dart';
import '/data/services/qq_music.dart';
@ -10,9 +10,6 @@ import '../../../data/services/navigation_manager.dart';
import '../../../data/services/ast_service.dart';
import '../../../data/services/speech_impl/azure_ast_service.dart';
import 'package:get/get.dart';
import '../../data/services/meeting/meeting_task_service.dart';
import '../../data/services/meeting/meeting_task_service2.dart';
import '../../data/services/meeting/meeting_task_aggregator.dart';
import '../../data/services/meeting/meeting_upload_service.dart';
import '../../data/services/novice_guide_service.dart';
import '../../data/services/speech_factory.dart';
@ -72,15 +69,6 @@ class InitialBinding extends Bindings {
// 会议助手任务服务
Get.lazyPut<MeetingTaskService>(() => MeetingTaskService(), fenix: true);
// 会议助手任务服务(极速识别版,2小时内音频)
Get.lazyPut<MeetingTaskService2>(() => MeetingTaskService2(), fenix: true);
// 会议助手任务服务聚合器(统一列表与状态)
Get.lazyPut<MeetingTaskAggregator>(() => MeetingTaskAggregator(),
fenix: true);
// 会议助手总结任务服务
Get.lazyPut<SummaryTaskService>(() => SummaryTaskService(), fenix: true);
// 会议助手音频上传
Get.lazyPut<MeetingUploadService>(() => MeetingUploadService(),
fenix: true);

9
lib/core/translations/language/en_us.dart

@ -437,6 +437,15 @@ const Map<String, String> enUS = {
'audioVideoRecording': 'Audio/Video Recording',
'callRecording': 'Call Recording',
'importAudio': 'Import Audio',
'meetingRecordExitTitle': 'Exit Recording', // 退出录音
'meetingRecordExitMessage': 'Choose what to do before leaving', // 请选择离开后的操作
'meetingRecordEndAndSave': 'End recording and save', // 结束录音并保存
'meetingRecordEndWithoutSave': 'End recording without saving', // 结束录音不保存
'meetingRecordContinueInBackground':
'Continue recording in background', // 后台继续录音
'meetingRecordRecordingNotificationTitle': 'Recording in progress', // 录音进行中
'meetingRecordRecordingNotificationBody':
'Recording in background. Tap to return to the app.', // 正在后台录音,点击返回应用
'transcribing': 'Transcribing...',
'summarizing': 'Summarizing...',
'analyzing': 'Analyzing...',

7
lib/core/translations/language/zh_cn.dart

@ -414,6 +414,13 @@ const Map<String, String> zhCN = {
'audioVideoRecording': '音、视频录音',
'callRecording': '通话录音',
'importAudio': '导入音频',
'meetingRecordExitTitle': '退出录音', // 退出录音
'meetingRecordExitMessage': '请选择离开后的操作', // 请选择离开后的操作
'meetingRecordEndAndSave': '结束录音并保存', // 结束录音并保存
'meetingRecordEndWithoutSave': '结束录音不保存', // 结束录音不保存
'meetingRecordContinueInBackground': '后台继续录音', // 后台继续录音
'meetingRecordRecordingNotificationTitle': '录音进行中', // 录音进行中
'meetingRecordRecordingNotificationBody': '正在后台录音,点击返回应用', // 正在后台录音,点击返回应用
'transcribing': '正在转写...',
'summarizing': '正在总结...',
'analyzing': '正在分析...',

131
lib/core/utils/synchrodata.dart

@ -1,9 +1,11 @@
import 'dart:convert';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../data/services/db/database_helper.dart';
import '../../data/services/db/sqflite_api.dart';
import '../../data/services/meeting/meeting_task_service.dart';
import '../../data/services/network/api.dart';
class Synchrodata {
@ -76,35 +78,35 @@ class Synchrodata {
static Future uploadOperationRecords({bool isSynchro = false}) async {
if (_isSynchro) return;
_isSynchro = true;
if (_records.isNotEmpty || _delids.isNotEmpty) {
// 获取需要修改的全部数据
List putRecords = [];
for (var element in _records) {
Map result = {'details': {}};
List speakerList = [];
if (!_delids.contains(element['id'])) {
result = await SqfliteApi.getMeetingDetails(element['number']);
speakerList = await SqfliteApi.getMeetingSpeaker(element['number']);
}
putRecords.add({
...Map<String, dynamic>.from(result),
...Map<String, dynamic>.from(result['details']),
'rtype': result['type'],
'original': jsonEncode(speakerList),
...Map<String, dynamic>.from(element),
});
}
var result = await Api.putOperationRecord({
'delids': _delids,
'records': putRecords,
});
if (result != null) {
_records = [];
_delids = [];
_storage.write("operation_records", _records);
_storage.write("operation_delids", _delids);
}
}
// if (_records.isNotEmpty || _delids.isNotEmpty) {
// // 获取需要修改的全部数据
// List putRecords = [];
// for (var element in _records) {
// Map result = {'details': {}};
// List speakerList = [];
// if (!_delids.contains(element['id'])) {
// result = await SqfliteApi.getMeetingDetails(element['number']);
// speakerList = await SqfliteApi.getMeetingSpeaker(element['number']);
// }
// putRecords.add({
// ...Map<String, dynamic>.from(result),
// ...Map<String, dynamic>.from(result['details']),
// 'rtype': result['type'],
// 'original': jsonEncode(speakerList),
// ...Map<String, dynamic>.from(element),
// });
// }
// var result = await Api.putOperationRecord({
// 'delids': _delids,
// 'records': putRecords,
// });
// if (result != null) {
// _records = [];
// _delids = [];
// _storage.write("operation_records", _records);
// _storage.write("operation_delids", _delids);
// }
// }
if (isSynchro) {
await _synchrodata();
}
@ -113,32 +115,40 @@ class Synchrodata {
// 同步数据
static Future _synchrodata() async {
var result = await Api.getOperationRecord();
var result = await Api.getOperationAllRecord();
List records = result['records'] ?? [];
if (records.isEmpty) return;
final meetingTaskService = Get.find<MeetingTaskService>();
List taskList = [];
final db = await DatabaseHelper().database;
await db.transaction((txn) async {
for (var element in records) {
if (element['state'] > 0 && element['state'] < 5) {
taskList.add({
'id': element['id'],
'tasktype': element['state'],
});
}
final List existList = await txn.query(
'meeting',
where: 'id = ?',
whereArgs: [element['number']],
whereArgs: [element['id']],
);
Map<String, Object?> meetingData = {
'id': element['number'],
'id': element['id'],
'title': element['title'] ?? '',
'type': element['rtype'] ?? '',
'seconds': element['seconds'] ?? 0,
'audiourl': element['audiourl'] ?? '',
'tasktype': element['tasktype'] ?? 0,
'creationtime': element['creationtime'] ?? 0,
'tasktype': element['state'] ?? 0,
'creationtime': (element['creationtime'] ?? 0) * 1000,
};
Map<String, Object?> meetingDetailsData = {
'meetingid': element['number'],
'meetingid': element['id'],
'address': element['address'] ?? '',
'personnel': element['personnel'] ?? '',
'taskid': element['taskid'] ?? '',
'tasktype': element['tasktype'] ?? 0,
'tasktype': element['state'] ?? 0,
'overview': element['overview'] ?? '',
'summary': element['summary'] ?? '',
'mindmap': '',
@ -148,13 +158,13 @@ class Synchrodata {
'meeting',
meetingData,
where: 'id = ?',
whereArgs: [element['number']],
whereArgs: [element['id']],
);
await txn.update(
'meetingdetails',
meetingDetailsData,
where: 'meetingid = ?',
whereArgs: [element['number']],
whereArgs: [element['id']],
);
} else {
await txn.insert('meeting', {
@ -163,48 +173,21 @@ class Synchrodata {
});
await txn.insert('meetingdetails', meetingDetailsData);
}
if (element['original'].isNotEmpty) {
final List existSpeakerList = await txn.query(
if (element['translate'].isNotEmpty) {
List speakerList = jsonDecode(element['translate']);
await txn.delete(
'meetingspeaker',
where: 'meetingid = ?',
whereArgs: [element['number']],
whereArgs: [element['id']],
);
List speakerList = jsonDecode(element['original']);
if (existSpeakerList.isNotEmpty) {
final batch = txn.batch();
for (var i = 0; i < existSpeakerList.length; i++) {
Map item = speakerList[i] ?? {};
if (item.isNotEmpty) {
batch.update(
'meetingspeaker',
{
'meetingid': item['meetingid'],
'content': item['content'],
'starttime': item['starttime'],
'endtime': item['endtime'],
'speaker': item['speaker'],
},
where: 'id = ?',
whereArgs: [existSpeakerList[i]['id']],
);
}
}
await batch.commit();
} else {
final batch = txn.batch();
for (var item in speakerList) {
batch.insert('meetingspeaker', {
'meetingid': item['meetingid'],
'content': item['content'],
'starttime': item['starttime'],
'endtime': item['endtime'],
'speaker': item['speaker'],
});
}
await batch.commit();
final batch = txn.batch();
for (var item in speakerList) {
batch.insert('meetingspeaker', item);
}
await batch.commit();
}
}
});
meetingTaskService.startTask(taskList);
}
}

7
lib/data/services/account_service.dart

@ -3,11 +3,10 @@ import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import '../../core/utils/synchrodata.dart';
import '../../modules/login/controllers/googleLogin.dart';
import '../../modules/login/controllers/facebookLogin.dart';
import '../services/network/api.dart';
import 'db/database_helper.dart';
import 'meeting/meeting_task_service.dart';
import '../../data/models/user_Info.dart';
import 'meeting/meeting_upload_service.dart';
/// 账号管理服务
///
@ -84,8 +83,8 @@ class AccountService extends GetxService {
Future<AccountOperationResult> signOut() async {
try {
// 清除会议记录任务列表
final taskService = Get.find<MeetingTaskService>();
taskService.cancelTask();
final uploadService = Get.find<MeetingUploadService>();
uploadService.cancelUpload();
// 上传会议记录操作记录
await Synchrodata.uploadOperationRecords();

29
lib/data/services/db/sqflite_api.dart

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../core/utils/synchrodata.dart';
import 'database_helper.dart';
class SqfliteApi {
@ -58,11 +57,10 @@ class SqfliteApi {
static Future insertMeeting(Map<String, Object?> meeting) async {
final db = await DatabaseHelper().database;
try {
int id = 0;
await db.transaction((txn) async {
id = await txn.insert('meeting', meeting);
await txn.insert('meeting', meeting);
await txn.insert('meetingdetails', {
'meetingid': id,
'meetingid': meeting['id'],
'address': '',
'personnel': '',
'taskid': '',
@ -71,12 +69,8 @@ class SqfliteApi {
'summary': '',
'mindmap': '',
});
Synchrodata.saveOperationRecord('insert', {
...meeting,
'id': id,
});
});
return id;
return true;
} catch (e) {
Get.snackbar(
'error'.tr, // 错误
@ -99,10 +93,6 @@ class SqfliteApi {
where: 'meetingid = ?',
whereArgs: [id],
);
Synchrodata.saveOperationRecord('update', {
...meeting,
'id': id,
});
return true;
} catch (e) {
Get.snackbar(
@ -168,14 +158,16 @@ class SqfliteApi {
}
}
// 删除商务会议助手转写内容
static Future deleteMeetingSpeaker(id) async {
final db = await DatabaseHelper().database;
await db.delete('meetingspeaker', where: 'meetingid = ?', whereArgs: [id]);
}
// 修改商务会议助手标题
static Future editMeetingTitle(id, Map<String, Object?> meeting) async {
final db = await DatabaseHelper().database;
await db.update('meeting', meeting, where: 'id = ?', whereArgs: [id]);
Synchrodata.saveOperationRecord('update', {
...meeting,
'id': id,
});
}
// 删除商务会议助手
@ -187,9 +179,6 @@ class SqfliteApi {
.delete('meetingdetails', where: 'meetingid = ?', whereArgs: [id]);
await txn
.delete('meetingspeaker', where: 'meetingid = ?', whereArgs: [id]);
Synchrodata.saveOperationRecord('delete', {
'id': id,
});
});
}

105
lib/data/services/meeting/meeting_task_aggregator.dart

@ -1,105 +0,0 @@
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'meeting_task_service.dart';
import 'meeting_task_service2.dart';
/// 会议助手任务服务聚合器(统一列表与状态)
class MeetingTaskAggregator extends GetxService {
final GetStorage _storage = GetStorage();
final MeetingTaskService s1 = Get.find<MeetingTaskService>();
final MeetingTaskService2 s2 = Get.find<MeetingTaskService2>();
RxList taskFinishList = [].obs;
RxInt tasktype = 1.obs;
int meetingId = 0;
List textList = [];
Worker? _w1;
Worker? _w2;
Worker? _wTasktype;
@override
void onInit() {
super.onInit();
_w1 = ever(s1.taskFinishList, (_) => _rebuild());
_w2 = ever(s2.taskFinishList, (_) => _rebuild());
_rebuild();
}
void setMeeting(int id) {
meetingId = id;
final String svc =
(_storage.read("meeting_service_type_$id") ?? '').toString();
final bool isFast = svc == 'fast';
_wTasktype?.dispose();
if (isFast) {
_wTasktype = ever(s2.tasktype, (value) {
tasktype.value = value;
if (value == 2) {
textList = s2.textList;
}
});
} else {
_wTasktype = ever(s1.tasktype, (value) {
tasktype.value = value;
if (value == 2) {
textList = s1.textList;
}
});
}
}
void clearMeeting() {
meetingId = 0;
_wTasktype?.dispose();
_wTasktype = null;
}
void _rebuild() {
final Set<int> ids = {};
for (var e in s1.taskFinishList) {
if (e is Map && e['id'] is int) ids.add(e['id'] as int);
}
for (var e in s2.taskFinishList) {
if (e is Map && e['id'] is int) ids.add(e['id'] as int);
}
final List unified = [];
for (final id in ids) {
final String svc =
(_storage.read("meeting_service_type_$id") ?? '').toString();
final Map a = s1.taskFinishList.firstWhere(
(x) => x['id'] == id,
orElse: () => {},
);
final Map b = s2.taskFinishList.firstWhere(
(x) => x['id'] == id,
orElse: () => {},
);
int va = a.isNotEmpty ? ((a['tasktype'] ?? 0) as int) : -999;
int vb = b.isNotEmpty ? ((b['tasktype'] ?? 0) as int) : -999;
int v;
if (svc == 'fast') {
if (va != -999 && vb != -999) {
v = va >= vb ? va : vb;
} else {
v = vb != -999 ? vb : va;
}
} else if (svc == 'standard') {
if (va != -999 && vb != -999) {
v = va >= vb ? va : vb;
} else {
v = va != -999 ? va : vb;
}
} else {
if (va == -999 && vb == -999) {
continue;
}
v = va == -999 ? vb : (vb == -999 ? va : (va >= vb ? va : vb));
}
unified.add({'id': id, 'tasktype': v});
}
taskFinishList.value = unified;
}
}

563
lib/data/services/meeting/meeting_task_service.dart

@ -1,133 +1,34 @@
import 'dart:convert';
import 'dart:io';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart' as http;
import '../../../core/utils/logger.dart';
import '../../../core/utils/synchrodata.dart';
import '../../models/meeting_usage_stats.dart';
import '../db/sqflite_api.dart';
import '../usage_stats_service.dart';
import '../network/api.dart';
import 'meeting_upload_service.dart';
import '../../../data/models/appconfig.dart';
import 'summary_task_service.dart';
///会议任务服务类,负责管理会议音频的转写任务处理流程,包括任务提交、状态轮询、结果处理和AI分析等功能。
///火山引擎录音文件识别大模型-标准版
class MeetingTaskService extends GetxService {
final GetStorage _storage = GetStorage();
MeetingUploadService get _uploadService => Get.find<MeetingUploadService>();
SummaryTaskService get _summaryTaskService => Get.find<SummaryTaskService>();
List _taskList = []; //待处理的会议任务列表
RxList taskFinishList = [].obs; //已处理的会议任务列表
bool _isTask = false; //是否有正在处理的任务
String _userId = '';
// final String _serviceUrl = 'https://openspeech.bytedance.com/api/v1/auc'; //
// final String _cluster = 'volc_auc_video_flash';
//字节跳动(火山引擎)语音服务配置
// final String _appid = '2316081933';
// final String _token = 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En';
// final String _cluster1 = 'volc.bigasr.auc';
// final String _serviceUrl1 =
// 'https://openspeech.bytedance.com/api/v3/auc/bigmodel'; //大模型录音文件识别api地址
late final String _appid;
late final String _token;
late final String _cluster1;
late final String _serviceUrl1;
late final String _modelName;
late final String _modelVersion;
Map<String, String> _headers = {};
Map<String, String> _headers1 = {};
RxInt updateListState = 0.obs; //是否更新首页列表
int meetingId = 0;
RxInt tasktype = 1.obs; //任务类型,1-转写中,2-总结中,3-完成
List textList = []; //转写结果列表
@override
void onInit() {
super.onInit();
_initialize();
_getAgent();
_getTaskList();
}
void cancelTask() {
_isTask = false;
_taskList.clear();
_storage.remove("meeting_task_list");
_storage.remove("meeting_task_finish_list");
_uploadService.cancelUpload();
_summaryTaskService.cancelTask();
}
Future<bool> _initialize() async {
try {
_appid = AppConfig.env('VOLC_OPENSPEECH_APP_ID') ?? '';
_token = AppConfig.env('VOLC_OPENSPEECH_ACCESS_TOKEN') ?? '';
_cluster1 = AppConfig.env('VOLC_OPENSPEECH_CLUSTER_BIGMODEL') ?? '';
_serviceUrl1 = AppConfig.env('VOLC_OPENSPEECH_BASE_URL_BIGMODEL') ?? '';
_modelName = AppConfig.env('VOLC_OPENSPEECH_MODEL_NAME') ?? 'bigmodel';
_modelVersion = AppConfig.env('VOLC_OPENSPEECH_MODEL_VERSION') ?? '400';
if (_appid.isEmpty ||
_token.isEmpty ||
_cluster1.isEmpty ||
_serviceUrl1.isEmpty ||
_modelName.isEmpty ||
_modelVersion.isEmpty) {
Logger.error('初始化会议任务服务失败:配置参数为空');
return false;
}
_headers = {
'Authorization': 'Bearer; $_token',
'Content-Type': 'application/json',
};
_headers1 = {
'X-Api-App-Key': _appid,
'X-Api-Access-Key': _token,
'X-Api-Resource-Id': _cluster1,
'X-Api-Request-Id': '',
'X-Api-Sequence': '-1',
};
} catch (e) {
Logger.error('初始化会议任务服务失败:$e');
return false;
}
return true;
}
void _getAgent() async {
Map userInfo = _storage.read("user_info");
_userId = userInfo['user']['uid'];
}
void _getTaskList() {
_taskList = _storage.read("meeting_task_list") ?? [];
taskFinishList.value = _storage.read("meeting_task_finish_list") ?? [];
_executeTask();
}
///添加会议任务
///[id] 会议ID
///[audiourl] 音频URL
///[taskid] 任务ID
///[isSpeaker] 是否为发言人
///[language] 语言
///[prompt] 提示词
///[outline] 大纲
Future<void> addTask(int id, String audiourl, String taskid, bool isSpeaker,
String language, String prompt, String outline) async {
RxInt tasktype = 1.obs; //任务类型,0-初始,1-等待转写中,2-转写中,3-等待总结中,4-总结中,5-完成
List textList = [];
String personnel = '';
String summary = '';
String overview = '';
Future<void> addTask(
int id,
String audiourl,
String formlanguage,
String tolanguage,
bool isdistinguishspeaker,
int templateid,
) async {
if (audiourl.isEmpty) {
Map uploadData = _uploadService.uploadList.firstWhere(
(element) => element['id'] == id,
@ -136,343 +37,169 @@ class MeetingTaskService extends GetxService {
if (uploadData.isNotEmpty) {
if (uploadData['audiourl'].isEmpty) {
uploadData['isTask'] = true;
uploadData['isSpeaker'] = isSpeaker;
uploadData['language'] = language;
uploadData['prompt'] = prompt;
uploadData['outline'] = outline;
uploadData['formlanguage'] = formlanguage;
uploadData['tolanguage'] = tolanguage;
uploadData['isdistinguishspeaker'] = isdistinguishspeaker;
uploadData['templateid'] = templateid;
} else {
await addTaskList(id, uploadData['audiourl'], taskid, isSpeaker,
language, prompt, outline);
await submitTask(
id,
formlanguage,
tolanguage,
isdistinguishspeaker,
templateid,
);
}
}
} else {
await addTaskList(
id, audiourl, taskid, isSpeaker, language, prompt, outline);
await submitTask(
id,
formlanguage,
tolanguage,
isdistinguishspeaker,
templateid,
);
}
}
///添加会议任务列表
///[id] 会议ID
///[audiourl] 音频URL
///[taskId] 任务ID
///[isSpeaker] 是否为发言人
///[language] 语言
///[prompt] 提示词
///[outline] 大纲
Future<void> addTaskList(int id, String audiourl, String taskId,
bool isSpeaker, String language, String prompt, String outline) async {
if (taskId.isEmpty) {
taskId = await _submitTask(audiourl, isSpeaker, language);
}
await SqfliteApi.editMeeting(id, {'tasktype': 1, 'taskid': taskId});
try {
final detail = await SqfliteApi.getMeetingDetails(id);
final seconds = (detail['seconds'] ?? 0) as int;
final filepath = (detail['filepath'] ?? '') as String;
int fileSizeBytes = 0;
if (filepath.isNotEmpty) {
try {
fileSizeBytes = await File(filepath).length();
} catch (_) {}
}
final record = MeetingAsrRecord(
id: taskId,
meetingId: id,
taskId: taskId,
audioSeconds: seconds,
fileSizeBytes: fileSizeBytes,
language: language,
submitTimestamp: DateTime.now().millisecondsSinceEpoch,
status: asrStatusProcessing,
transcriptBytes: 0,
completeTimestamp: 0,
);
try {
final svc = Get.isRegistered<UsageStatsService>()
? Get.find<UsageStatsService>()
: Get.put(UsageStatsService(), permanent: true);
svc.addMeetingAsrRecord(record);
} catch (e) {
Logger.w('MeetingASRrecord', '提交用量记录失败: ' + e.toString());
}
} catch (_) {}
Future<void> submitTask(
int id,
String formlanguage,
String tolanguage,
bool isdistinguishspeaker,
int templateid,
) async {
await Api.startTaskEchomeet({
'id': id,
'formlanguage': formlanguage,
'tolanguage': tolanguage,
'isdistinguishspeaker': isdistinguishspeaker,
'templateid': templateid,
});
_taskList.add({
'id': id,
'taskid': taskId,
'tasktype': 1,
'prompt': prompt,
'outline': outline,
});
taskFinishList.add({
await SqfliteApi.editMeetingTitle(
id,
{'tasktype': 1},
);
updateListState.value++;
_executeTask();
}
Future<void> refreshTask(int id, int templateid) async {
tasktype.value = 3;
await Api.refreshTaskEchomeet({
'id': id,
'tasktype': 1,
'templateid': templateid,
});
_taskList.add({
'id': id,
'tasktype': 3,
});
_storage.write("meeting_task_list", _taskList);
_storage.write("meeting_task_finish_list", taskFinishList);
await SqfliteApi.editMeetingTitle(
id,
{'tasktype': 3},
);
updateListState.value++;
_executeTask();
}
///执行会议任务
Future<void> readTask(int id) async {
await Api.readRecordEchomeet({'id': id});
await SqfliteApi.editMeetingTitle(
id,
{'tasktype': 6},
);
updateListState.value++;
}
void _executeTask() async {
if (_isTask) return;
_isTask = true;
while (_taskList.isNotEmpty) {
await Future.delayed(const Duration(seconds: 2));
Logger.info("lxm--轮询---会议任务列表$_taskList");
for (var task in List.from(_taskList)) {
await _fileRecognize(task);
}
await Future.delayed(const Duration(seconds: 5));
await _queryTask();
}
_isTask = false;
}
void removeTask(int id, {bool isDeleteTask = false}) {
Map taskFinishData = taskFinishList.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
if (isDeleteTask || taskFinishData['tasktype'] >= 3) {
taskFinishList.remove(taskFinishData);
_storage.write("meeting_task_finish_list", taskFinishList);
}
}
if (isDeleteTask) {
_taskList.removeWhere((element) => element['id'] == id);
_storage.write("meeting_task_list", _taskList);
_summaryTaskService.removeTask(id);
}
}
//
Future<String> _submitTask(
String audiourl, bool isSpeaker, String language) async {
String format = _getFileType(audiourl);
final taskId = DateTime.now().millisecondsSinceEpoch.toString();
_headers1['X-Api-Request-Id'] = taskId;
Logger.info("lxm--大模型asr请求头$_headers1");
final requestBody1 = jsonEncode({
"user": {
"uid": _userId,
},
"audio": {
"url": audiourl,
"language": language,
"format": format,
},
"request": {
"model_name": _modelName, // 模型名称,目前只有bigmodel
"model_version": _modelVersion, // 模型版本,400模型性能略有提升,且ITN有较大优化
//"enable_itn": true, // 启用文本规范化,默认为true
"enable_punc": true, // 启用标点,默认为false
//"enable_ddc": false, // 启用语义顺滑,默认为false
"enable_speaker_info": isSpeaker, // 启用说话人聚类分离,10人以内效果较好
//"enable_channel_split": false, // 启用双声道识别,默认为false
//"show_utterances": true, // 输出语音停顿、分句、分词信息
//"show_speech_rate": false, // 分句信息携带语速
//"show_volume": false, // 分句信息携带音量
//"enable_lid": false, // 启用语种识别
}
Future<void> removeTask(int id) async {
_taskList.removeWhere((element) => element['id'] == id);
await Api.delRecordEchomeet({
'ids': [id],
});
Logger.info("lxm--大模型asr请求体$requestBody1");
// final requestBody = jsonEncode({
// "app": {
// "appid": _appid,
// "token": _token,
// "cluster": _cluster,
// },
// "user": {
// "uid": _userId,
// },
// "audio": {
// "format": format,
// "url": audiourl,
// },
// "additions": {
// "language": language,
// "with_speaker_info": isSpeaker ? "True" : "False",
// }
// });
try {
final response = await http.post(
Uri.parse('$_serviceUrl1/submit'),
headers: _headers1,
body: requestBody1,
);
Logger.info("lxm--大模型asr响应体${response.body}");
Logger.info("lxm--大模型asr上传响应头${response.headers['x-tt-logid']}");
Logger.info(
"lxm--大模型asr上传服务端返回的状态码${response.headers['x-api-status-code']}");
Logger.info("lxm--大模型asr上传服务端返回的消息${response.headers['x-api-message']}");
if (response.statusCode != 200) {
throw Exception('Submit failed with status: ${response.statusCode}');
}
//final respDic = jsonDecode(response.body);
//final taskId = respDic['resp']['id'] as String;
//Logger.debug('taskId: $taskId');
return taskId;
} catch (e) {
Logger.error('Submit error: ${e.toString()}');
rethrow;
}
}
String _getFileType(String path) {
List<String> array = path.split('.');
return array[array.length - 1];
}
///轮询语音转文字任务状态
///[taskId] 任务ID
Future<Map<String, dynamic>> _queryTask(String taskId) async {
// final queryBody = jsonEncode({
// "appid": _appid,
// "token": _token,
// "id": taskId,
// "cluster": _cluster,
// });
try {
_headers1['X-Api-Request-Id'] = taskId;
final response = await http.post(
Uri.parse('$_serviceUrl1/query'),
headers: _headers1,
body: jsonEncode({}),
);
// Logger.info("lxm---大模型asr响应体${response.body}");
// Logger.info("lxm---大模型asr响应头${response.headers}");
// Logger.info("lxm---大模型asr响应状态码${response.statusCode}");
// Logger.info("lxm---大模型asr响应体logid${response.headers['x-tt-logid']}");
// Logger.info("lxm---大模型asr服务端返回的状态码${response.headers['x-api-status-code']}");
// Logger.info("lxm---大模型asr服务端返回的消息${response.headers['x-api-message']}");
if (response.statusCode != 200) {
throw Exception('Query failed with status: ${response.statusCode}');
}
final respDic = jsonDecode(response.body);
return respDic;
} catch (e) {
Logger.error('Query error: ${e.toString()}');
rethrow;
}
void startTask(List data) {
_taskList = data;
_executeTask();
}
///轮询语音转文字任务状态,若任务完成则处理转写结果
///[item] 包含任务ID、会议ID、AI总结提示词、AI概览提示词的Map
Future<void> _fileRecognize(Map item) async {
try {
var respDic = await _queryTask(item['taskid']);
//var code = respDic['resp']['code'] as int;
Logger.info("lxm---大模型asr响应体结果$respDic['result']['text']");
if (respDic['result']['text'] != null &&
respDic['result']['text'] != '') {
await _addTextList(
item['id'], //会议ID
respDic['result']['utterances'], //语音转文字结果
item['prompt'], //AI总结提示词
item['outline'], //AI概览提示词
);
return;
} else {
Logger.debug("FileRecognize failed");
return;
}
} catch (e) {
Logger.error('Error occurred: ${e.toString()}');
}
void stopTask() {
_taskList.clear();
}
///处理语音识别完成后的转写结果,包括数据库存储、状态更新和触发AI分析
///[id] 会议ID
///[utterances] 语音转文字结果
///[prompt] AI总结提示词
///[outline] AI概览提示词
Future<void> _addTextList(
int id, List utterances, String prompt, String outline) async {
String text = '';
String personnel = '';
List<Map<String, Object?>> list = [];
// 处理转写结果,将每个句子的开始时间、结束时间、发言人和内容存储到列表中
for (var element in utterances) {
String speaker = '';
if (element['additions'].isNotEmpty) {
speaker = 'Speaker ${element['additions']['speaker']}';
text += '[$speaker]: ';
if (!personnel.contains(speaker)) {
personnel += '[$speaker] ';
}
}
list.add({
'meetingid': id,
'content': element['text'],
'starttime': element['start_time'],
'endtime': element['end_time'],
'speaker': speaker,
});
text += '${element['text']}\n';
}
// 存储处理后的转写结果到数据库
var result = await SqfliteApi.insertMeetingSpeaker(list);
if (result != null) {
// 更新会议状态为“总结中”
await SqfliteApi.editMeeting(
id,
{'tasktype': 2, 'personnel': personnel},
);
List speakerList = await SqfliteApi.getMeetingSpeaker(id);
List newSpeakerList = List.from(speakerList);
Synchrodata.saveOperationRecord('update', {
'original': jsonEncode(newSpeakerList),
'id': id,
});
if (id == meetingId) {
textList = newSpeakerList;
tasktype.value = 2;
}
_taskList.removeWhere((element) => element['id'] == id);
Map taskFinishData = taskFinishList.firstWhere(
(element) => element['id'] == id,
Future<void> _queryTask() async {
if (_taskList.isEmpty) return;
List ids = _taskList.map((e) => e['id']).toList();
var response = await Api.getMultitermRecord({
'ids': ids,
});
List queryList = response['records'] ?? [];
for (var element in queryList) {
int id = element['id'];
int newTasktype = element['state'];
Map taskData = _taskList.firstWhere(
(i) => i['id'] == id,
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
taskFinishData['tasktype'] = 2;
taskFinishList.refresh();
if (taskData.isNotEmpty) {
int oldTasktype = taskData['tasktype'];
if (oldTasktype != newTasktype) {
if (oldTasktype < 3 && newTasktype >= 3) {
// 转写完成
List<dynamic> decodeList = jsonDecode(element['translate']);
List<Map<String, Object?>> speakerList =
decodeList.map((e) => Map<String, Object?>.from(e)).toList();
await SqfliteApi.deleteMeetingSpeaker(id);
await SqfliteApi.insertMeetingSpeaker(speakerList);
await SqfliteApi.editMeeting(
id,
{'tasktype': newTasktype},
);
if (id == meetingId) {
textList = speakerList;
tasktype.value = newTasktype;
}
}
if (oldTasktype < 5 && newTasktype >= 5) {
// 总结完成
await SqfliteApi.editMeeting(id, {
'tasktype': newTasktype,
'personnel': element['personnel'] ?? '',
'summary': element['summary'] ?? '',
'overview': element['overview'] ?? '',
});
if (id == meetingId) {
personnel = element['personnel'] ?? '';
summary = element['summary'] ?? '';
overview = element['overview'] ?? '';
tasktype.value = newTasktype;
}
}
await SqfliteApi.editMeetingTitle(
id,
{'tasktype': newTasktype},
);
taskData['tasktype'] = newTasktype;
updateListState.value++;
}
}
_storage.write("meeting_task_list", _taskList);
_storage.write("meeting_task_finish_list", taskFinishList);
}
try {
final details = await SqfliteApi.getMeetingDetails(id);
final tid = (details['taskid'] ?? '') as String;
final transcriptBytes = utf8.encode(text).length;
try {
final svc = Get.isRegistered<UsageStatsService>()
? Get.find<UsageStatsService>()
: Get.put(UsageStatsService(), permanent: true);
svc.completeMeetingAsrRecord(
id: tid,
transcriptBytes: transcriptBytes,
completeTimestamp: DateTime.now().millisecondsSinceEpoch,
);
} catch (e) {
Logger.w('MeetingASRrecord', '完成用量记录失败: ' + e.toString());
if (newTasktype >= 5) {
_taskList.removeWhere((i) => i['id'] == id);
}
} catch (_) {}
// 触发AI分析,分别请求总结和概览
_summaryTaskService.addTaskList(id, text, prompt, outline);
}
}
}

971
lib/data/services/meeting/meeting_task_service2.dart

@ -1,971 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart' as http;
import '../db/sqflite_api.dart';
import '../../../data/models/appconfig.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/synchrodata.dart';
import '../usage_stats_service.dart';
import '../../../data/models/meeting_usage_stats.dart';
import '../deapsound_ai_service.dart';
import 'meeting_upload_service.dart';
// 移除标准版服务桥接,改为通过聚合器对外暴露统一状态
import 'summary_task_service.dart';
//火山引擎录音文件识别大模型-极速版
class MeetingTaskService2 extends GetxService {
final GetStorage _storage = GetStorage();
MeetingUploadService get _uploadService => Get.find<MeetingUploadService>();
SummaryTaskService get _summaryTaskService => Get.find<SummaryTaskService>();
List _taskList = [];
RxList taskFinishList = [].obs;
bool _isTask = false;
String _systemPrompt = '';
String _userId = '';
late final String _appid;
late final String _token;
late final String _serviceUrl;
int meetingId = 0;
RxInt tasktype = 1.obs;
List textList = [];
String summary = '';
String overview = '';
@override
void onInit() {
super.onInit();
_getAgent();
_getTaskList();
}
void _getAgent() async {
Map userInfo = _storage.read("user_info");
_userId = userInfo['user']['uid'];
_systemPrompt = AppConfig.isInitialized()
? (AppConfig.agentsystemPrompt("meet_assistant") ?? '')
: '';
final String base = AppConfig.env('VOLC_OPENSPEECH_BASE_URL_BIGMODEL') ??
'https://openspeech.bytedance.com/api/v3/auc/bigmodel';
_appid = AppConfig.env('VOLC_OPENSPEECH_APP_ID') ?? '';
_token = AppConfig.env('VOLC_OPENSPEECH_ACCESS_TOKEN') ?? '';
_serviceUrl =
base.endsWith('/recognize/flash') ? base : (base + '/recognize/flash');
}
void _getTaskList() {
_taskList = _storage.read("meeting_task_list_v2") ?? [];
taskFinishList.value = _storage.read("meeting_task_finish_list_v2") ?? [];
_executeTask();
}
Future<void> addTask(
int id,
String audiourl, {
bool isSpeaker = false,
String language = '',
String targetLanguage = '',
String prompt = '',
String outline = '',
}) async {
if (audiourl.isEmpty) {
Map uploadData = _uploadService.uploadList.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (uploadData.isNotEmpty) {
if (uploadData['audiourl'].isEmpty) {
uploadData['isTask'] = true;
uploadData['template'] = {
'isSpeaker': isSpeaker,
'language': language,
'targetLanguage': targetLanguage,
'prompt': prompt,
'outline': outline,
};
_storage.write("meeting_upload_list", _uploadService.uploadList);
} else {
await addTaskList(
id,
uploadData['audiourl'],
isSpeaker: isSpeaker,
language: language,
targetLanguage: targetLanguage,
prompt: prompt,
outline: outline,
);
}
}
} else {
await addTaskList(
id,
audiourl,
isSpeaker: isSpeaker,
language: language,
targetLanguage: targetLanguage,
prompt: prompt,
outline: outline,
);
}
}
Future<void> addTaskList(
int id,
String audiourl, {
bool isSpeaker = false,
String language = '',
String targetLanguage = '',
String prompt = '',
String outline = '',
}) async {
final String taskId = DateTime.now().millisecondsSinceEpoch.toString();
await SqfliteApi.editMeeting(id, {'tasktype': 1, 'taskid': taskId});
try {
final detail = await SqfliteApi.getMeetingDetails(id);
final seconds = (detail['seconds'] ?? 0) as int;
final filepath = (detail['filepath'] ?? '') as String;
int fileSizeBytes = 0;
if (filepath.isNotEmpty) {
try {
fileSizeBytes = await File(filepath).length();
} catch (_) {}
}
final record = MeetingAsrRecord(
id: taskId,
meetingId: id,
taskId: taskId,
audioSeconds: seconds,
fileSizeBytes: fileSizeBytes,
language: language,
submitTimestamp: DateTime.now().millisecondsSinceEpoch,
status: asrStatusProcessing,
transcriptBytes: 0,
completeTimestamp: 0,
);
try {
final svc = Get.isRegistered<UsageStatsService>()
? Get.find<UsageStatsService>()
: Get.put(UsageStatsService(), permanent: true);
svc.addMeetingAsrRecord(record);
} catch (e) {
Logger.w('MeetingASRrecord', '提交用量记录失败: ' + e.toString());
}
} catch (_) {}
_taskList.add({
'id': id,
'audiourl': audiourl,
'tasktype': 1,
'taskid': taskId,
'template': {
'isSpeaker': isSpeaker,
'language': language,
'targetLanguage': targetLanguage,
'prompt': prompt,
'outline': outline,
},
});
taskFinishList.add({'id': id, 'tasktype': 1});
_storage.write("meeting_task_list_v2", _taskList);
_storage.write("meeting_task_finish_list_v2", taskFinishList);
// 由聚合器统一对外呈现状态,不直接写入标准版列表
_executeTask();
}
void _executeTask() async {
if (_isTask) return;
_isTask = true;
Logger.debug('开始执行任务,任务数量:${_taskList.length}');
while (_taskList.isNotEmpty) {
Logger.debug('任务循环执行,剩余任务:${_taskList.length}');
await Future.delayed(const Duration(seconds: 2));
// 创建任务列表的副本,避免在迭代过程中修改原列表
List tasksCopy = List.from(_taskList);
for (var element in tasksCopy) {
Logger.debug('处理任务:${element['id']},音频:${element['audiourl']}');
// 检查任务是否仍在列表中(可能已被其他地方移除)
if (_taskList.any((task) => task['id'] == element['id'])) {
try {
await _fileRecognize(element);
} catch (e) {
Logger.error('处理任务失败:${element['id']},错误:$e');
// 确保失败的任务被移除,避免无限循环
_taskList.removeWhere((task) => task['id'] == element['id']);
_storage.write("meeting_task_list_v2", _taskList);
// 更新任务状态为失败
await SqfliteApi.editMeeting(element['id'], {'tasktype': 0});
}
} else {
Logger.debug('任务 ${element['id']} 已从任务列表移除');
}
}
// 如果没有任务被处理,避免无限循环
if (_taskList.length == tasksCopy.length) {
Logger.warning('本次循环未处理任何任务,为避免无限执行终止循环');
break;
}
}
_isTask = false;
Logger.debug('任务执行完成,剩余任务:${_taskList.length}');
}
void removeTask(int id, {bool isDeleteTask = false}) {
Map taskFinishData = taskFinishList.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
if (isDeleteTask || taskFinishData['tasktype'] >= 3) {
taskFinishList.remove(taskFinishData);
_storage.write("meeting_task_finish_list_v2", taskFinishList);
}
}
if (isDeleteTask) {
_taskList.removeWhere((element) => element['id'] == id);
_storage.write("meeting_task_list_v2", _taskList);
}
}
Future<Map<String, dynamic>> _recognizeAudio(
String audiourl, {
bool enableSpeakerInfo = true,
String language = '',
String? requestIdOverride,
}) async {
String requestId =
requestIdOverride ?? DateTime.now().millisecondsSinceEpoch.toString();
// 为每次请求生成新的headers - 适配大模型极速版API
Map<String, String> requestHeaders = {
'X-Api-App-Key': _appid,
'X-Api-Access-Key': _token,
'X-Api-Resource-Id': 'volc.bigasr.auc_turbo',
'X-Api-Request-Id': requestId,
'X-Api-Sequence': '-1',
'Content-Type': 'application/json',
};
// 使用大模型极速版API的新请求格式
final Map<String, dynamic> requestPayload = {
"user": {"uid": _appid},
"audio": {"url": audiourl},
"request": {
"model_name": "bigmodel",
"enable_itn": true,
"enable_punc": true,
"enable_ddc": true,
"enable_speaker_info": enableSpeakerInfo,
"word_info": 1,
"show_utterances": true
}
};
if (language.isNotEmpty) {
requestPayload['request']['language'] = language;
}
final String requestBody = jsonEncode(requestPayload);
try {
// 记录请求详情
Logger.debug('API 请求地址:$_serviceUrl');
Logger.debug('API 请求头:$requestHeaders');
Logger.debug('API 请求体:$requestBody');
final response = await http.post(
Uri.parse(_serviceUrl),
headers: requestHeaders,
body: requestBody,
);
Logger.debug('API 响应状态码:${response.statusCode}');
Logger.debug('API 响应头:${response.headers}');
if (response.statusCode != 200) {
Logger.error('识别失败,状态码:${response.statusCode},响应体:${response.body}');
throw Exception('识别失败,状态码:${response.statusCode}');
}
final respDic = jsonDecode(response.body);
Logger.debug('识别接口响应:$respDic');
// 检查响应中是否包含资源相关信息
if (respDic.containsKey('resource_id') || respDic.containsKey('model')) {
Logger.debug('资源校验 - 使用的资源ID:volc.bigasr.auc_turbo');
Logger.debug(
'响应中的资源信息:${respDic['resource_id'] ?? respDic['model'] ?? '响应未包含'}');
}
return respDic;
} catch (e) {
Logger.error('识别请求异常:${e.toString()}');
rethrow;
}
}
String _getFileType(String path) {
List<String> array = path.split('.');
return array[array.length - 1];
}
int _parseTime(dynamic timeValue) {
if (timeValue == null) return 0;
if (timeValue is int) {
return timeValue;
} else if (timeValue is double) {
return timeValue.round();
} else if (timeValue is String) {
try {
double parsed = double.parse(timeValue);
return parsed.round();
} catch (e) {
Logger.error('时间值解析失败:$timeValue');
return 0;
}
} else {
Logger.error('未知的时间值类型:${timeValue.runtimeType},值:$timeValue');
return 0;
}
}
Future<void> _fileRecognize(Map item) async {
try {
Logger.debug('开始识别音频:${item['audiourl']}');
// 识别语言选取:若设置了目标输出语言且与录音语言一致,优先让ASR自动识别,避免错配
final String tplLang =
(((item['template'] ?? {})['language']) ?? '').toString();
final String tplTarget =
(((item['template'] ?? {})['targetLanguage']) ?? '').toString();
String asrLanguage = tplLang;
if (tplTarget.isNotEmpty && tplTarget == tplLang) {
asrLanguage = '';
}
var respDic = await _recognizeAudio(
item['audiourl'],
enableSpeakerInfo:
(((item['template'] ?? {})['isSpeaker']) ?? false) as bool,
language: asrLanguage,
requestIdOverride: (item['taskid'] ?? '').toString().isEmpty
? null
: (item['taskid'] ?? '').toString(),
);
Logger.debug('API 全量响应结构:${respDic.toString()}');
Logger.debug('响应包含的键:${respDic.keys.toList()}');
// 检查响应格式并提取utterances
List utterances = [];
// 检查不同可能的响应格式
if (respDic['result'] != null &&
respDic['result']['utterances'] != null) {
utterances = respDic['result']['utterances'];
Logger.debug('在 result.utterances 中找到 ${utterances.length} 条语句');
} else if (respDic['resp'] != null &&
respDic['resp']['utterances'] != null) {
utterances = respDic['resp']['utterances'];
Logger.debug('在 resp.utterances 中找到 ${utterances.length} 条语句');
} else if (respDic['utterances'] != null) {
utterances = respDic['utterances'];
Logger.debug('在根级 utterances 中找到 ${utterances.length} 条语句');
} else if (respDic['data'] != null &&
respDic['data']['utterances'] != null) {
utterances = respDic['data']['utterances'];
Logger.debug('在 data.utterances 中找到 ${utterances.length} 条语句');
} else if (respDic['response'] != null &&
respDic['response']['utterances'] != null) {
utterances = respDic['response']['utterances'];
Logger.debug('在 response.utterances 中找到 ${utterances.length} 条语句');
} else {
// 检查是否有错误信息
if (respDic['error'] != null) {
Logger.error('接口返回错误:${respDic['error']}');
} else if (respDic['message'] != null) {
Logger.error('接口返回消息:${respDic['message']}');
} else {
Logger.error('未知的响应格式。可用键:${respDic.keys.toList()}');
// 尝试查找任何包含文本的字段
respDic.forEach((key, value) {
Logger.debug('键:$key,值类型:${value.runtimeType},值:$value');
});
}
}
if (utterances.isNotEmpty) {
Logger.debug('开始处理 ${utterances.length} 条语句');
await _addTextList(item['id'], utterances, item['template']);
// 从任务列表中移除已完成的任务
_taskList.removeWhere((element) => element['id'] == item['id']);
_storage.write("meeting_task_list_v2", _taskList);
Logger.debug('音频识别成功,会议ID:${item['id']}');
// 更新UI状态 - 如果是当前会议,触发UI更新
if (item['id'] == meetingId) {
tasktype.value = 2; // 转写完成,进入分析阶段
}
} else {
Logger.error('响应中未发现语句。完整响应:$respDic');
Logger.debug('未检测到语句,开始执行无声音频检查');
Logger.debug('无声音频检测调试信息');
Logger.debug('响应键列表:${respDic.keys.toList()}');
Logger.debug('响应代码:${respDic['code']}');
Logger.debug('响应消息:${respDic['message']}');
// 检查是否为无声音频
bool isSilentAudio = _checkIfSilentAudio(respDic);
Logger.debug('无声检测结果:$isSilentAudio');
if (isSilentAudio) {
Logger.debug('检测到无声音频,设置任务类型为 -1');
// 设置无声音频状态 (使用特殊值 -1)
await SqfliteApi.editMeeting(item['id'], {'tasktype': -1});
if (item['id'] == meetingId) {
Logger.debug('当前会议设置 tasktype.value 为 -1');
tasktype.value = -1; // 无声音频
}
// 更新taskFinishList中的状态
Map taskFinishData = taskFinishList.firstWhere(
(element) => element['id'] == item['id'],
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
taskFinishData['tasktype'] = -1;
taskFinishList.refresh();
}
_storage.write("meeting_task_finish_list_v2", taskFinishList);
// 由聚合器统一对外呈现状态,不直接写入标准版列表
// 由聚合器统一对外呈现状态,不直接写入标准版列表
} else {
Logger.error('识别失败,原因未知');
// 识别失败,更新数据库状态为失败
await SqfliteApi.editMeeting(item['id'], {'tasktype': 0});
if (item['id'] == meetingId) {
tasktype.value = 0; // 识别失败
}
// 更新taskFinishList中的状态
Map taskFinishData = taskFinishList.firstWhere(
(element) => element['id'] == item['id'],
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
taskFinishData['tasktype'] = 0;
taskFinishList.refresh();
}
_storage.write("meeting_task_finish_list_v2", taskFinishList);
}
// 从任务列表中移除
_taskList.removeWhere((element) => element['id'] == item['id']);
_storage.write("meeting_task_list_v2", _taskList);
}
} catch (e) {
Logger.error('文件识别流程发生异常:${e.toString()}');
Logger.error('堆栈信息:${StackTrace.current}');
// 更新数据库状态为失败
try {
await SqfliteApi.editMeeting(item['id'], {'tasktype': 0});
} catch (dbError) {
Logger.error('数据库更新会议状态失败:$dbError');
}
// 出错时也要从任务列表中移除,避免无限重试
_taskList.removeWhere((element) => element['id'] == item['id']);
_storage.write("meeting_task_list_v2", _taskList);
// 更新UI状态 - 如果是当前会议,显示错误状态
if (item['id'] == meetingId) {
tasktype.value = 0; // 识别失败
}
}
}
/// 检查是否为无声音频
bool _checkIfSilentAudio(Map<String, dynamic> response) {
Logger.debug('检查是否为无声音频,响应:${response.toString()}');
// 检查API响应中的错误信息或状态码
// 根据实际API响应格式调整检测逻辑
// 检查是否有错误码表示无声音频
if (response['code'] != null) {
int code = response['code'];
Logger.debug('检测到响应代码:$code');
// 常见的无声音频错误码
if (code == 40001 ||
code == 40002 ||
code == 40003 ||
code == 50001 ||
code == 50002 ||
code == 50003) {
Logger.debug('根据错误码判断为无声音频:$code');
return true;
}
}
// 检查错误消息
if (response['message'] != null) {
String message = response['message'].toString().toLowerCase();
Logger.debug('检测到响应消息:$message');
if (message.contains('silent') ||
message.contains('no audio') ||
message.contains('无声') ||
message.contains('静音') ||
message.contains('empty audio') ||
message.contains('no speech') ||
message.contains('audio too short') ||
message.contains('insufficient audio')) {
Logger.debug('根据消息判断为无声音频:$message');
return true;
}
}
// 检查result中的错误信息
if (response['result'] != null && response['result']['error'] != null) {
String error = response['result']['error'].toString().toLowerCase();
Logger.debug('检测到 result.error:$error');
if (error.contains('silent') ||
error.contains('no audio') ||
error.contains('无声') ||
error.contains('静音') ||
error.contains('empty audio') ||
error.contains('no speech') ||
error.contains('audio too short')) {
Logger.debug('根据 result.error 判断为无声音频:$error');
return true;
}
}
// 检查是否有音频时长信息且时长为0或极短
if (response['result'] != null && response['result']['duration'] != null) {
double duration = (response['result']['duration'] as num).toDouble();
Logger.debug('检测到音频时长:$duration 秒');
if (duration <= 0.5) {
// 音频时长小于0.5秒认为是无声
Logger.debug('根据时长过短判断为无声音频:$duration');
return true;
}
}
// 检查utterances是否为空且没有其他明确的成功标识
bool hasUtterances = false;
if (response['result'] != null &&
response['result']['utterances'] != null) {
hasUtterances = (response['result']['utterances'] as List).isNotEmpty;
} else if (response['resp'] != null &&
response['resp']['utterances'] != null) {
hasUtterances = (response['resp']['utterances'] as List).isNotEmpty;
} else if (response['utterances'] != null) {
hasUtterances = (response['utterances'] as List).isNotEmpty;
} else if (response['data'] != null &&
response['data']['utterances'] != null) {
hasUtterances = (response['data']['utterances'] as List).isNotEmpty;
} else if (response['response'] != null &&
response['response']['utterances'] != null) {
hasUtterances = (response['response']['utterances'] as List).isNotEmpty;
}
// 检查文本内容是否为空
String textContent = '';
if (response['result'] != null && response['result']['text'] != null) {
textContent = response['result']['text'].toString().trim();
} else if (response['text'] != null) {
textContent = response['text'].toString().trim();
} else if (response['data'] != null && response['data']['text'] != null) {
textContent = response['data']['text'].toString().trim();
}
// 如果文本内容为空且音频时长大于1秒,很可能是无声音频
if (textContent.isEmpty) {
double audioDuration = 0;
if (response['audio_info'] != null &&
response['audio_info']['duration'] != null) {
audioDuration = (response['audio_info']['duration'] as num).toDouble() /
1000; // 转换为秒
} else if (response['result'] != null &&
response['result']['additions'] != null &&
response['result']['additions']['duration'] != null) {
audioDuration =
(response['result']['additions']['duration'] as num).toDouble() /
1000; // 转换为秒
}
Logger.debug('文本内容为空,音频时长:$audioDuration 秒');
if (audioDuration > 1.0) {
// 音频时长大于1秒但没有文本内容,认为是无声音频
Logger.debug('判断为无声音频:文本为空且时长充足');
return true;
}
}
// 如果没有utterances且响应状态正常,可能是无声音频
if (!hasUtterances) {
// 检查是否有明确的成功状态码
bool hasSuccessCode = false;
if (response['code'] != null) {
int code = response['code'];
hasSuccessCode = (code == 0 || code == 200);
}
// 如果有成功状态码但没有utterances,很可能是无声音频
if (hasSuccessCode) {
Logger.debug('判断为无声音频:成功状态码但无语句');
return true;
}
}
Logger.debug('未判定为无声音频');
return false;
}
Future<void> _addTextList(int id, List utterances,
[Map? templateData]) async {
Logger.debug('处理会议 $id 的 ${utterances.length} 条语句');
String text = '';
String personnel = '';
List<Map<String, Object?>> list = [];
for (int i = 0; i < utterances.length; i++) {
var element = utterances[i];
Logger.debug('处理第 $i 条语句:${element.toString()}');
String speaker = '';
String content = '';
int startTime = 0;
int endTime = 0;
// 提取说话人信息 - 检查多种可能的字段名
if (element['additions'] != null && element['additions'].isNotEmpty) {
if (element['additions']['speaker'] != null) {
speaker = 'Speaker ${element['additions']['speaker']}';
}
} else if (element['speaker'] != null) {
speaker = 'Speaker ${element['speaker']}';
} else if (element['speaker_id'] != null) {
speaker = 'Speaker ${element['speaker_id']}';
}
// 提取文本内容 - 检查多种可能的字段名
if (element['text'] != null) {
content = element['text'].toString();
} else if (element['content'] != null) {
content = element['content'].toString();
} else if (element['transcript'] != null) {
content = element['transcript'].toString();
} else {
Logger.error('语句中未找到文本内容:$element');
continue;
}
// 提取时间信息 - 检查多种可能的字段名和格式
if (element['start_time'] != null) {
startTime = _parseTime(element['start_time']);
} else if (element['startTime'] != null) {
startTime = _parseTime(element['startTime']);
} else if (element['begin'] != null) {
startTime = _parseTime(element['begin']);
}
if (element['end_time'] != null) {
endTime = _parseTime(element['end_time']);
} else if (element['endTime'] != null) {
endTime = _parseTime(element['endTime']);
} else if (element['end'] != null) {
endTime = _parseTime(element['end']);
}
if (speaker.isNotEmpty) {
text += '[$speaker]: ';
if (!personnel.contains(speaker)) {
personnel += '[$speaker] ';
}
}
list.add({
'meetingid': id,
'content': content,
'starttime': startTime,
'endtime': endTime,
'speaker': speaker,
});
text += '$content\n';
Logger.debug('已添加语句:说话人=$speaker,内容=$content,开始=$startTime,结束=$endTime');
}
Logger.debug('处理完成的语句总数:${list.length}');
var result = await SqfliteApi.insertMeetingSpeaker(list);
if (result != null) {
// 初始用于总结/概览的文本,先用原始拼接文本作为默认值
String summarySourceText = text;
// 根据目标语言决定是否需要翻译文本列表
final String targetLanguage =
((templateData?['targetLanguage']) ?? '').toString();
final String sourceLanguage =
((templateData?['language']) ?? '').toString();
bool needTranslate = false;
if (targetLanguage.isNotEmpty) {
// 若未指定录音语言,默认认为需要翻译;否则当目标语言与录音语言主码不同则翻译
if (sourceLanguage.isEmpty) {
needTranslate = true;
} else {
String srcPrefix = sourceLanguage.toLowerCase().substring(0, 2);
String tgtPrefix = targetLanguage.toLowerCase().substring(0, 2);
needTranslate = srcPrefix != tgtPrefix;
}
}
if (needTranslate) {
try {
// 保持在“正在转写”状态,先进行翻译为目标语言
await _translateTextList(id, targetLanguage);
} catch (e) {
Logger.error('转写文本翻译失败:${e.toString()}');
}
// 翻译完成后再写入 personnel、设置已完成并刷新 UI
await SqfliteApi.editMeeting(
id, {'tasktype': 2, 'personnel': personnel});
List speakerList = await SqfliteApi.getMeetingSpeaker(id);
List newSpeakerList = List.from(speakerList);
Synchrodata.saveOperationRecord('update', {
'original': jsonEncode(newSpeakerList),
'id': id,
});
if (id == meetingId) {
textList = newSpeakerList;
tasktype.value = 2;
}
// 由聚合器统一对外呈现状态,不直接写入标准版列表
// 由聚合器统一对外呈现状态,不直接写入标准版列表
// 用最新(已翻译)的文本重建总结/概览输入
summarySourceText = '';
for (var element in newSpeakerList) {
final String spk = (element['speaker'] ?? '').toString();
final String cnt = (element['content'] ?? '').toString();
if (spk.isNotEmpty) {
summarySourceText += '[$spk]: ';
}
summarySourceText += '$cnt\n';
}
Map taskFinishData = taskFinishList.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
taskFinishData['tasktype'] = 2;
taskFinishList.refresh();
}
_storage.write("meeting_task_finish_list_v2", taskFinishList);
} else {
// 原有中文直出流程
await SqfliteApi.editMeeting(
id, {'tasktype': 2, 'personnel': personnel});
List speakerList = await SqfliteApi.getMeetingSpeaker(id);
List newSpeakerList = List.from(speakerList);
Synchrodata.saveOperationRecord('update', {
'original': jsonEncode(newSpeakerList),
'id': id,
});
if (id == meetingId) {
textList = newSpeakerList;
tasktype.value = 2;
}
// 仍使用最新DB中的文本构建总结/概览输入,避免与原始拼接文本不一致
summarySourceText = '';
for (var element in newSpeakerList) {
final String spk = (element['speaker'] ?? '').toString();
final String cnt = (element['content'] ?? '').toString();
if (spk.isNotEmpty) {
summarySourceText += '[$spk]: ';
}
summarySourceText += '$cnt\n';
}
Map taskFinishData = taskFinishList.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
taskFinishData['tasktype'] = 2;
taskFinishList.refresh();
}
_storage.write("meeting_task_finish_list_v2", taskFinishList);
}
// 使用统一的总结服务生成概览与总结
final String tplPrompt = ((templateData?['prompt']) ?? '').toString();
final String tplOutline = ((templateData?['outline']) ?? '').toString();
final String outCode = (((templateData?['targetLanguage']) ??
(templateData?['language']) ??
'') as String)
.toLowerCase();
String langDesc = '';
if (outCode.startsWith('zh')) {
langDesc = '中文';
} else if (outCode.startsWith('en')) {
langDesc = '英文';
} else if (outCode.startsWith('ja')) {
langDesc = '日文';
} else if (outCode.startsWith('ko')) {
langDesc = '韩文';
} else if (outCode.startsWith('fr')) {
langDesc = '法文';
} else if (outCode.startsWith('de')) {
langDesc = '德文';
} else if (outCode.startsWith('es')) {
langDesc = '西班牙文';
} else if (outCode.startsWith('ru')) {
langDesc = '俄文';
} else if (outCode.startsWith('it')) {
langDesc = '意大利文';
} else if (outCode.startsWith('pt')) {
langDesc = '葡萄牙文';
} else if (outCode.startsWith('th')) {
langDesc = '泰文';
} else if (outCode.startsWith('vi')) {
langDesc = '越南文';
}
final String promptToUse = tplPrompt.isEmpty
? (langDesc.isEmpty
? ''
: '输出语言:' + langDesc + '。请全程使用' + langDesc + '输出。')
: (langDesc.isEmpty
? tplPrompt
: tplPrompt + ' 输出语言:' + langDesc + '。请全程使用' + langDesc + '输出。');
final String outlineToUse = tplOutline.isEmpty
? (langDesc.isEmpty
? ''
: '输出语言:' + langDesc + '。请全程使用' + langDesc + '输出。')
: (langDesc.isEmpty
? tplOutline
: tplOutline + ' 输出语言:' + langDesc + '。请全程使用' + langDesc + '输出。');
try {
final details = await SqfliteApi.getMeetingDetails(id);
final tid = (details['taskid'] ?? '') as String;
final transcriptBytes = utf8.encode(summarySourceText).length;
final svc = Get.isRegistered<UsageStatsService>()
? Get.find<UsageStatsService>()
: Get.put(UsageStatsService(), permanent: true);
svc.completeMeetingAsrRecord(
id: tid,
transcriptBytes: transcriptBytes,
completeTimestamp: DateTime.now().millisecondsSinceEpoch,
);
} catch (e) {
Logger.w('MeetingASRrecord', '完成用量记录失败: ' + e.toString());
}
_summaryTaskService.addTaskList(
id, summarySourceText, promptToUse, outlineToUse);
}
}
Future<void> _translateTextList(int id, String targetLanguage) async {
// 读取刚插入的文本条目
List speakerList = await SqfliteApi.getMeetingSpeaker(id);
// 构造简洁的可翻译列表
List toTranslate = speakerList
.map((e) => {
'id': e['id'],
'speaker': (e['speaker'] ?? '').toString(),
'content': (e['content'] ?? '').toString(),
})
.toList();
final String userJson = jsonEncode({
'targetLanguage': targetLanguage,
'items': toTranslate,
});
// 根据目标语言构建提示语
String tgtName = targetLanguage.toLowerCase();
String langDesc = '目标语言';
if (tgtName.startsWith('zh')) {
langDesc = '中文';
} else if (tgtName.startsWith('en')) {
langDesc = '英文';
} else if (tgtName.startsWith('ja')) {
langDesc = '日文';
} else if (tgtName.startsWith('ko')) {
langDesc = '韩文';
} else if (tgtName.startsWith('fr')) {
langDesc = '法文';
} else if (tgtName.startsWith('de')) {
langDesc = '德文';
} else if (tgtName.startsWith('es')) {
langDesc = '西班牙文';
} else if (tgtName.startsWith('ru')) {
langDesc = '俄文';
} else if (tgtName.startsWith('it')) {
langDesc = '意大利文';
} else if (tgtName.startsWith('pt')) {
langDesc = '葡萄牙文';
} else if (tgtName.startsWith('th')) {
langDesc = '泰文';
} else if (tgtName.startsWith('vi')) {
langDesc = '越南文';
}
final String systemPrompt = '你是专业的翻译助手。请将每条items中的content精准翻译为' +
langDesc +
',保持术语专业、语气自然。仅返回JSON数组,元素形如{"id":<原id>,"content":"<翻译后文本>"},不要额外说明,不要漏掉任何一条。';
final String? translated = await DeapsoundAIService().sendMessage(
messages: [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userJson},
],
);
if (translated == null || translated.isEmpty) return;
List<dynamic> arr;
try {
arr = jsonDecode(translated);
} catch (_) {
// 若模型返回包裹对象,尝试取字段
try {
final obj = jsonDecode(translated);
arr = (obj is Map && obj['data'] is List) ? (obj['data'] as List) : [];
} catch (e) {
Logger.error('翻译结果 JSON 解析失败:${e.toString()}');
return;
}
}
if (arr.isEmpty) return;
// 批量更新
for (var item in arr) {
if (item is Map && item['id'] != null) {
await SqfliteApi.editMeetingSpeaker([
{
'id': item['id'],
'content': (item['content'] ?? '').toString(),
}
]);
}
}
// 刷新缓存
if (id == meetingId) {
textList = await SqfliteApi.getMeetingSpeaker(id);
// 主动触发监听刷新 UI(MeetingDetailsController 通过监听 tasktype 同步 textList)
try {
tasktype.refresh();
} catch (_) {}
}
}
}

62
lib/data/services/meeting/meeting_upload_service.dart

@ -4,10 +4,10 @@ import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/upload_oss.dart';
import '../network/api.dart';
import '../usage_stats_service.dart';
import '../db/sqflite_api.dart';
import 'meeting_task_service.dart';
import 'meeting_task_service2.dart';
/// 管理会议音频文件上传服务
class MeetingUploadService extends GetxService {
@ -47,26 +47,20 @@ class MeetingUploadService extends GetxService {
String rootDir, {
bool isUpload = true,
}) async {
bool flash = false;
int fileSize = 0;
try {
fileSize = await File(filePath).length(); //获取文件大小
final details = await SqfliteApi.getMeetingDetails(id);
final int seconds = (details['seconds'] ?? 0) as int;
flash = seconds > 0 && seconds <= 7200;
fileSize = await File(filePath).length();
} catch (_) {}
_storage.write("meeting_service_type_$id", flash ? 'fast' : 'standard');
uploadList.add({
'id': id,
'filepath': filePath,
'rootdir': rootDir,
'audiourl': '',
'isTask': false,
'isSpeaker': true,
'language': '',
'prompt': '',
'outline': '',
'useFlashASR': flash,
'formlanguage': '',
'tolanguage': '',
'isdistinguishspeaker': true,
'templateid': 0,
'fileSizeBytes': fileSize,
});
_storage.write("meeting_upload_list", uploadList);
@ -85,18 +79,20 @@ class MeetingUploadService extends GetxService {
if (element['audiourl'].isEmpty) {
meetingId.value = element['id'];
try {
int _sent = 0;
int _total = 0;
int totalProgress = 0;
final audiourl = await UploadOss.upload(
filepath: element['filepath'],
rootDir: element['rootdir'],
onSendProgress: (int count, int total) {
// uploadProgress.value = count / total;
_sent = count;
_total = total;
totalProgress = total;
uploadProgress.value = total > 0 ? count / total : 0.0;
},
);
await Api.upRecordEchomeet({
'id': element['id'],
'audiourl': audiourl,
});
await SqfliteApi.editMeetingTitle(
element['id'],
{'audiourl': audiourl},
@ -110,7 +106,8 @@ class MeetingUploadService extends GetxService {
try {
fileSizeBytes = element['fileSizeBytes'] ?? 0;
} catch (_) {}
final transferred = _total > 0 ? _total : fileSizeBytes;
final transferred =
totalProgress > 0 ? totalProgress : fileSizeBytes;
svc.recordMeetingObjectStorage(
meetingId: element['id'],
storageUrl: audiourl,
@ -119,31 +116,16 @@ class MeetingUploadService extends GetxService {
uploadTimestamp: DateTime.now().millisecondsSinceEpoch,
);
} catch (e) {
Logger.w('MeetingOSrecord', '对象存储用量记录失败: ' + e.toString());
Logger.w('MeetingOSrecord', '对象存储用量记录失败: $e');
}
if (element['isTask']) {
if (element['useFlashASR'] == true) {
final service2 = Get.find<MeetingTaskService2>();
await service2.addTaskList(
element['id'],
audiourl,
isSpeaker: element['isSpeaker'],
language: element['language'],
targetLanguage: '',
prompt: element['prompt'],
outline: element['outline'],
);
} else {
await _taskService.addTaskList(
element['id'],
audiourl,
'',
element['isSpeaker'],
element['language'],
element['prompt'],
element['outline'],
);
}
await _taskService.submitTask(
element['id'],
element['formlanguage'],
element['tolanguage'],
element['isdistinguishspeaker'],
element['templateid'],
);
}
element['audiourl'] = audiourl;
uploadProgress.value = 0.0;

187
lib/data/services/meeting/summary_task_service.dart

@ -1,187 +0,0 @@
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart';
import '../db/sqflite_api.dart';
import '../deapsound_ai_service.dart';
import 'meeting_task_service.dart';
import 'meeting_task_service2.dart';
class SummaryTaskService extends GetxService {
final _taskService = Get.find<MeetingTaskService>();
final _taskService2 = Get.find<MeetingTaskService2>();
final GetStorage _storage = GetStorage();
List _taskList = [];
bool _isTask = false;
String summary = ''; //AI总结内容
String overview = ''; //AI概览内容
@override
void onInit() {
super.onInit();
_getTaskList();
}
void cancelTask() {
_taskList.clear();
_storage.remove("summary_task_list");
}
void _getTaskList() {
_taskList = _storage.read("summary_task_list") ?? [];
_executeTask();
}
Future<void> addTaskList(
int id, String text, String prompt, String outline) async {
Logger.debug("添加总结任务列表");
_taskList.add({
'id': id,
'text': text,
'prompt': prompt,
'outline': outline,
});
_storage.write("summary_task_list", _taskList);
_executeTask();
}
void _executeTask() async {
if (_taskList.isEmpty) return;
if (_isTask) return;
_isTask = true;
var task = _taskList.first;
_aiServiceMessage(
task['id'],
task['text'],
task['prompt'],
task['outline'],
);
Logger.debug("执行总结任务列表");
}
void removeTask(int id) {
_taskList.removeWhere((element) => element['id'] == id);
_storage.write("summary_task_list", _taskList);
}
void _aiServiceMessage(int id, String text, String prompt, String outline) {
final ai = DeapsoundAIService();
String summaryText = '';
String overviewText = '';
var summaryDone = false;
var overviewDone = false;
var summarySuccess = false;
var overviewSuccess = false;
void tryFinish() {
if (summaryDone && overviewDone) {
if (summarySuccess &&
overviewSuccess &&
summaryText.isNotEmpty &&
overviewText.isNotEmpty) {
_aiServiceValue(id, summaryText, overviewText);
} else {
_isTask = false;
_executeTask();
}
}
}
final summaryStream = ai.sendMessageStream(messages: [
{'role': 'system', 'content': prompt},
{'role': 'user', 'content': text},
]).timeout(const Duration(seconds: 120));
final overviewStream = ai.sendMessageStream(messages: [
{'role': 'system', 'content': outline},
{'role': 'user', 'content': text},
]).timeout(const Duration(seconds: 120));
summaryStream.listen((chunk) {
summaryText += chunk;
}, onError: (error) {
summaryDone = true;
summarySuccess = false;
tryFinish();
}, onDone: () {
summaryDone = true;
summarySuccess = true;
tryFinish();
}, cancelOnError: true);
overviewStream.listen((chunk) {
overviewText += chunk;
}, onError: (error) {
overviewDone = true;
overviewSuccess = false;
tryFinish();
}, onDone: () {
overviewDone = true;
overviewSuccess = true;
tryFinish();
}, cancelOnError: true);
}
Future<void> _aiServiceValue(
int id, String summaryText, String overviewText) async {
final exists = _taskList.any((item) => item['id'] == id);
if (!exists) {
_isTask = false;
_executeTask();
return;
}
await SqfliteApi.editMeeting(id, {
'tasktype': 3,
'summary': summaryText,
'overview': overviewText,
});
// 更新 V1 Service 状态
if (id == _taskService.meetingId) {
summary = summaryText;
overview = overviewText;
_taskService.tasktype.value = 3;
}
// 更新 V2 Service 状态
if (id == _taskService2.meetingId) {
summary = summaryText;
overview = overviewText;
_taskService2.summary = summaryText;
_taskService2.overview = overviewText;
_taskService2.tasktype.value = 3;
}
// 更新 V1 任务完成列表
Map taskFinishData = _taskService.taskFinishList.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (taskFinishData.isNotEmpty) {
taskFinishData['tasktype'] = 3;
_taskService.taskFinishList.refresh();
}
_storage.write("meeting_task_finish_list", _taskService.taskFinishList);
// 更新 V2 任务完成列表
Map taskFinishData2 = _taskService2.taskFinishList.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (taskFinishData2.isNotEmpty) {
taskFinishData2['tasktype'] = 3;
_taskService2.taskFinishList.refresh();
}
_storage.write("meeting_task_finish_list_v2", _taskService2.taskFinishList);
_taskList.removeWhere((element) => element['id'] == id);
_storage.write("summary_task_list", _taskList);
_isTask = false;
_executeTask();
Logger.debug("总结任务完成");
}
}

75
lib/data/services/network/api.dart

@ -119,7 +119,16 @@ class Api {
}
// 获取会议助手数据
static getOperationRecord([params]) {
static getOperationAllRecord([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_getallrecords',
params: params,
);
}
// 轮询多条会议助手转写结果
static getMultitermRecord([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_getrecords',
@ -291,4 +300,68 @@ class Api {
params: params,
);
}
static addRecordEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_addrecord',
params: params,
);
}
static upRecordEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_uprecord',
params: params,
);
}
static startTaskEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_starttask',
params: params,
);
}
static refreshTaskEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_summary',
params: params,
);
}
static readRecordEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_readrecord',
params: params,
);
}
static delRecordEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_delrecords',
params: params,
);
}
static modifyRecordEchomeet([params]) {
Map data = {
'title': 'NULL',
'address': 'NULL',
'personnel': 'NULL',
'translate': 'NULL',
'summary': 'NULL',
};
data.addAll(params);
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_modifyrecords',
params: data,
);
}
}

6
lib/modules/meeting/bindings/meeting_record_binding.dart

@ -5,7 +5,9 @@ import '../controllers/meeting_record_controller.dart';
class MeetingRecordBinding extends Bindings {
@override
void dependencies() {
// 延迟注册 MeetingRecordController 控制器
Get.lazyPut<MeetingRecordController>(() => MeetingRecordController());
if (!Get.isRegistered<MeetingRecordController>()) {
Get.put<MeetingRecordController>(MeetingRecordController(),
permanent: true);
}
}
}

122
lib/modules/meeting/controllers/meeting_details_controller.dart

@ -9,16 +9,12 @@ import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:path_provider/path_provider.dart';
import '../../../core/utils/synchrodata.dart';
import '../../../data/services/db/sqflite_api.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
import '../../../data/services/meeting/meeting_task_service2.dart';
import '../../../data/services/meeting/meeting_upload_service.dart';
import '../../../data/services/meeting/summary_task_service.dart';
import '../../../data/services/meeting/meeting_task_aggregator.dart';
import '../../../data/services/network/api.dart';
import '../model/meeting_details_model.dart';
import '../model/meeting_model.dart';
@ -31,12 +27,9 @@ class MeetingDetailsController extends GetxController
late AnimationController lottieController;
late final Worker _tasktypeListener;
late final Worker _tasktypeListenerV2;
final _meetingUploadService = Get.find<MeetingUploadService>();
final _meetingTaskService = Get.find<MeetingTaskService>();
final _meetingTaskService2 = Get.find<MeetingTaskService2>();
final _summaryTaskService = Get.find<SummaryTaskService>();
final PlayerController playerController = PlayerController();
@ -95,39 +88,19 @@ class MeetingDetailsController extends GetxController
..duration = const Duration(seconds: 2);
final Map<String, dynamic> args = Get.arguments ?? {};
id = args['id'];
final storage = GetStorage();
final String svc =
(storage.read("meeting_service_type_$id") ?? '').toString();
final bool isFast = svc == 'fast';
_meetingTaskService.meetingId = id;
_meetingTaskService2.meetingId = id;
Get.find<MeetingTaskAggregator>().setMeeting(id);
_getDataDetails();
_loadJS();
_tasktypeListener = ever(_meetingTaskService.tasktype, (value) {
if (isFast && value != 3) return;
meetingDetails.value.tasktype = value;
if (_everType <= value) {
if (!isFast && value == 2) {
textList.value = _meetingTaskService.textList;
} else if (value == 3) {
meetingDetails.value.summary = _summaryTaskService.summary;
meetingDetails.value.overview = _summaryTaskService.overview;
summaryNumberModifications.value++;
}
}
meetingDetails.refresh();
});
_tasktypeListenerV2 = ever(_meetingTaskService2.tasktype, (value) {
if (!isFast) return;
meetingDetails.value.tasktype = value;
if (_everType <= value) {
if (value == 2) {
textList.value = _meetingTaskService2.textList;
} else if (value == 3) {
meetingDetails.value.summary = _summaryTaskService.summary;
meetingDetails.value.overview = _summaryTaskService.overview;
if (value >= 5) {
meetingDetails.value.personnel = _meetingTaskService.personnel;
meetingDetails.value.summary = _meetingTaskService.summary;
meetingDetails.value.overview = _meetingTaskService.overview;
summaryNumberModifications.value++;
} else if (value >= 3) {
textList.value = _meetingTaskService.textList;
}
}
meetingDetails.refresh();
@ -137,22 +110,17 @@ class MeetingDetailsController extends GetxController
@override
void onClose() {
_tasktypeListener.dispose();
_tasktypeListenerV2.dispose();
_currentDurationSubscription?.cancel();
_playerStateSubscription?.cancel();
tabController.dispose();
playerController.dispose();
lottieController.dispose();
Get.find<MeetingTaskAggregator>().clearMeeting();
super.onClose();
}
void popInvokedWithResult(didPop, result) async {
_meetingUploadService.removeUpload(id);
_meetingTaskService.removeTask(id);
_meetingTaskService2.removeTask(id);
_meetingTaskService.meetingId = 0;
_meetingTaskService2.meetingId = 0;
}
void _getDataDetails() async {
@ -161,14 +129,17 @@ class MeetingDetailsController extends GetxController
meetingDetails.value = MeetingDetailsModel.fromJson(data['details']);
_everType = meetingDetails.value.tasktype + 1;
_meetingTaskService.tasktype.value = meetingDetails.value.tasktype;
if (meetingDetails.value.tasktype >= 3) {
if (meetingDetails.value.tasktype == 5) {
await _meetingTaskService.readTask(id);
}
if (meetingDetails.value.tasktype >= 5) {
dragOffset.value = 0;
isTop.value = true;
}
if (meetingData.value.filepath.isNotEmpty) {
await _loadPlayerController();
}
if (meetingDetails.value.tasktype >= 2) {
if (meetingDetails.value.tasktype >= 3) {
List speakerList = await SqfliteApi.getMeetingSpeaker(id);
textList.value = List.from(speakerList);
}
@ -267,9 +238,9 @@ class MeetingDetailsController extends GetxController
textList[index] = dataMap;
}
}
Synchrodata.saveOperationRecord('update', {
'original': jsonEncode(textList),
await Api.modifyRecordEchomeet({
'id': id,
'translate': jsonEncode(textList),
});
isSpeakerText.value = false;
editSpeakerTextId.value = 0;
@ -284,64 +255,33 @@ class MeetingDetailsController extends GetxController
}
void summaryCreate(
bool isSpeaker, String language, String prompt, String outline) async {
String formlanguage,
String tolanguage,
bool isdistinguishspeaker,
int templateid,
) async {
EasyLoading.show();
_everType = 2;
final String audiourl = meetingData.value.audiourl;
final int seconds = meetingData.value.seconds;
final storage = GetStorage();
storage.write("meeting_service_type_$id",
(seconds > 0 && seconds <= 7200) ? 'fast' : 'standard');
if (seconds > 0 && seconds <= 7200) {
await _meetingTaskService2.addTask(
id,
audiourl,
isSpeaker: isSpeaker,
language: language,
targetLanguage: '',
prompt: prompt,
outline: outline,
);
} else {
final String taskId = meetingDetails.value.taskid;
await _meetingTaskService.addTask(
id,
audiourl,
taskId,
isSpeaker,
language,
prompt,
outline,
);
}
await _meetingTaskService.addTask(
id,
audiourl,
formlanguage,
tolanguage,
isdistinguishspeaker,
templateid,
);
meetingDetails.value.tasktype = 1;
meetingDetails.refresh();
EasyLoading.dismiss();
}
void refreshSummary(String prompt, String outline) async {
String text = '';
for (var element in textList) {
if (element['speaker'].isNotEmpty) {
text += '[${element['speaker']}]: ';
}
text += '${element['content']}\n';
}
_everType = 3;
void refreshSummary(int templateid) async {
EasyLoading.show();
await SqfliteApi.editMeeting(id, {'tasktype': 2});
meetingDetails.value.tasktype = 2;
_everType = 4;
await _meetingTaskService.refreshTask(id, templateid);
meetingDetails.value.tasktype = 3;
meetingDetails.refresh();
final storage = GetStorage();
final String svc =
(storage.read("meeting_service_type_$id") ?? '').toString();
final bool isFast = svc == 'fast';
if (isFast) {
_meetingTaskService2.tasktype.value = 2;
} else {
_meetingTaskService.tasktype.value = 2;
}
_summaryTaskService.addTaskList(id, text, prompt, outline);
EasyLoading.dismiss();
}
}

79
lib/modules/meeting/controllers/meeting_home_controller.dart

@ -19,10 +19,8 @@ import '../../../core/utils/permission_util.dart';
import 'package:share_plus/share_plus.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
import '../../../data/services/meeting/meeting_task_service2.dart';
import '../../../data/services/meeting/meeting_task_aggregator.dart';
import '../../../data/services/meeting/meeting_upload_service.dart';
import '../../../data/services/meeting/summary_task_service.dart';
import '../../../data/services/network/api.dart';
import '../../../routes/app_routes.dart';
import 'meeting_controller.dart';
@ -31,9 +29,6 @@ class MeetingHomeController extends GetxController {
final meetingController = Get.find<MeetingController>();
final meetingUploadService = Get.find<MeetingUploadService>();
final meetingTaskService = Get.find<MeetingTaskService>();
final meetingTaskService2 = Get.find<MeetingTaskService2>();
final taskAggregator = Get.find<MeetingTaskAggregator>();
final summaryTaskService = Get.find<SummaryTaskService>();
final AudioPlayer _audioPlayer = AudioPlayer();
@ -53,6 +48,8 @@ class MeetingHomeController extends GetxController {
// 蓝牙服务
final bleManager = Get.find<BleManager>();
late final Worker _updateListStateListener;
@override
void onInit() {
super.onInit();
@ -68,6 +65,10 @@ class MeetingHomeController extends GetxController {
final String path = args['filePath'];
addAudio(path);
}
_updateListStateListener =
ever(meetingTaskService.updateListState, (value) async {
_getDataList();
});
}
@override
@ -75,11 +76,13 @@ class MeetingHomeController extends GetxController {
titleController.dispose();
filtrateController.dispose();
_audioPlayer.dispose();
_updateListStateListener.dispose();
super.onClose();
}
void onPopInvokedWithResult(didPop, result) {
Synchrodata.uploadOperationRecords(isSynchro: true);
// Synchrodata.uploadOperationRecords(isSynchro: true);
meetingTaskService.stopTask();
}
void _initGetDataList() async {
@ -246,8 +249,16 @@ class MeetingHomeController extends GetxController {
};
// 插入数据库
final meetingId = await SqfliteApi.insertMeeting(data);
if (meetingId != null) {
final response = await Api.addRecordEchomeet({
'rtype': 'LOCAL',
...data,
});
final meetingId = response['record']['id'];
final meetingResult = await SqfliteApi.insertMeeting({
'id': meetingId,
...data,
});
if (meetingResult != null) {
// 安排后台上传任务
meetingUploadService.addUpload(meetingId, newPath, 'LocalAudio');
@ -359,8 +370,16 @@ class MeetingHomeController extends GetxController {
'creationtime': creationTime,
};
final meetingId = await SqfliteApi.insertMeeting(data);
if (meetingId != null) {
final response = await Api.addRecordEchomeet({
'rtype': recordType,
...data,
});
final meetingId = response['record']['id'];
final meetingResult = await SqfliteApi.insertMeeting({
'id': meetingId,
...data,
});
if (meetingResult != null) {
// 中文注释:上传分类按目录区分
final uploadBucket = archiveToLocal ? 'LocalAudio' : 'TranslatAudio';
meetingUploadService.addUpload(meetingId, usePath, uploadBucket);
@ -475,8 +494,13 @@ class MeetingHomeController extends GetxController {
'tasktype': 0,
'creationtime': creationTime,
};
final meetingId = await SqfliteApi.insertMeeting(data);
if (meetingId != null) {
final response = await Api.addRecordEchomeet({'rtype': type, ...data});
final meetingId = response['record']['id'];
final meetingResult = await SqfliteApi.insertMeeting({
'id': meetingId,
...data,
});
if (meetingResult != null) {
// 中文注释:根据类型选择上传分类;LOCAL 走 LocalAudio,其它走 ExternalAudio
final uploadBucket = type == 'LOCAL' ? 'LocalAudio' : 'ExternalAudio';
meetingUploadService.addUpload(
@ -544,24 +568,24 @@ class MeetingHomeController extends GetxController {
return targetPath;
}
// 移除原来的_saveFile方法,替换为上面的_copyFileToLocal
Future<String> _saveFile(PlatformFile file) async {
final directory = await getApplicationDocumentsDirectory();
final directoryDir = Directory('${directory.path}/ExternalAudio');
if (!await directoryDir.exists()) {
await directoryDir.create(recursive: true);
}
final String filePath = '${directory.path}/ExternalAudio/${file.name}';
final File localFile = File(filePath);
await localFile.writeAsBytes(file.bytes!);
return filePath;
}
// 移除原来的_saveFile方法,替换为上面的_copyFileToLocal
// Future<String> _saveFile(PlatformFile file) async {
// final directory = await getApplicationDocumentsDirectory();
// final directoryDir = Directory('${directory.path}/ExternalAudio');
// if (!await directoryDir.exists()) {
// await directoryDir.create(recursive: true);
// }
// final String filePath = '${directory.path}/ExternalAudio/${file.name}';
// final File localFile = File(filePath);
// await localFile.writeAsBytes(file.bytes!);
// return filePath;
// }
// 修改标题
void editMeetingTitle(int id) async {
if (titleController.text.isNotEmpty) {
String title = titleController.text + titleCSuffix;
Api.modifyRecordEchomeet({'id': id, 'title': title});
SqfliteApi.editMeetingTitle(id, {'title': title});
int originalIndex =
originalDataList.indexWhere((item) => item['id'] == id);
@ -589,8 +613,7 @@ class MeetingHomeController extends GetxController {
SqfliteApi.deleteMeeting(id);
_deleteFile(filepath);
meetingUploadService.removeUpload(id, isDeleteTask: true);
meetingTaskService.removeTask(id, isDeleteTask: true);
meetingTaskService2.removeTask(id, isDeleteTask: true);
meetingTaskService.removeTask(id);
}
// 删除文件

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

@ -4,12 +4,17 @@ import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import '/core/utils/logger.dart';
import '/core/utils/overlay_permission_util.dart';
import '/core/utils/permission_util.dart';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:native_plugin/native_plugin.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../../../routes/app_routes.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/ble_manager.dart';
@ -24,7 +29,187 @@ enum TimerStatus {
/// 负责会议记录的状态管理、音频处理、语音识别和UI交互
class MeetingRecordController extends GetxController
with GetTickerProviderStateMixin {
// Services
static const int _backgroundRecordingNotificationId = 91001;
static FlutterLocalNotificationsPlugin? _localNotificationsPlugin;
final NativePlugin _nativePlugin = NativePlugin();
Timer? _recordingFloatingBarTimer;
StreamSubscription<String>? _recordingFloatingActionSubscription;
bool _floatingBarManuallyClosed = false;
Future<FlutterLocalNotificationsPlugin>
_ensureLocalNotificationsPlugin() async {
final existing = _localNotificationsPlugin;
if (existing != null) return existing;
final plugin = FlutterLocalNotificationsPlugin();
const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings();
const settings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await plugin.initialize(settings);
_localNotificationsPlugin = plugin;
return plugin;
}
Future<void> _requestNotificationPermissionIfNeeded() async {
await PermissionUtil.instance.requestPermission(
permissionType: Permission.notification,
permissionName: 'notificationPermission'.tr,
explanationText: 'notificationPermissionExplanation'.tr,
permanentDenialText: 'notificationPermissionPermanentDenial'.tr,
settingsButtonText: 'goToSettings'.tr,
cancelButtonText: 'cancel'.tr,
);
}
Future<void> showBackgroundRecordingIndicator() async {
if (Platform.isAndroid) {
if (_floatingBarManuallyClosed) return;
await _startAndroidRecordingFloatingBar();
return;
}
await _requestNotificationPermissionIfNeeded();
if (!Platform.isIOS) return;
final plugin = await _ensureLocalNotificationsPlugin();
final details = NotificationDetails(
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
),
);
await plugin.show(
_backgroundRecordingNotificationId,
'meetingRecordRecordingNotificationTitle'.tr,
'meetingRecordRecordingNotificationBody'.tr,
details,
);
}
Future<void> clearBackgroundRecordingIndicator() async {
if (Platform.isAndroid) {
_floatingBarManuallyClosed = false;
await _stopAndroidRecordingFloatingBar();
return;
}
if (!Platform.isIOS) return;
final plugin = await _ensureLocalNotificationsPlugin();
await plugin.cancel(_backgroundRecordingNotificationId);
}
String _formatTimeForFloatingBar(int ms) {
final totalSeconds = ms ~/ 1000;
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final secs = totalSeconds % 60;
if (hours > 0) {
return '$hours:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
String _getRecordingTypeText() {
switch (audioType.value) {
case 0:
return 'liveRecording'.tr;
case 1:
return 'audioVideoRecording'.tr;
case 2:
return 'callRecording'.tr;
default:
return 'liveRecording'.tr;
}
}
Future<void> _startAndroidRecordingFloatingBar() async {
if (!Platform.isAndroid) return;
if (!isRecording.value) return;
if (_floatingBarManuallyClosed) return;
final hasPermission = await OverlayPermissionUtil.hasOverlayPermission();
if (!hasPermission) {
final granted = await OverlayPermissionUtil.requestOverlayPermission();
if (!granted) return;
}
final typeText = _getRecordingTypeText();
final durationText = _formatTimeForFloatingBar(milliseconds.value);
await _nativePlugin.showRecordingFloatingBar(
duration: durationText,
recordingType: typeText,
isPaused: timerStatus.value == TimerStatus.paused,
);
_recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer =
Timer.periodic(const Duration(seconds: 1), (_) {
if (!isRecording.value) {
_stopAndroidRecordingFloatingBar();
return;
}
_nativePlugin.updateRecordingFloatingBar(
duration: _formatTimeForFloatingBar(milliseconds.value),
recordingType: _getRecordingTypeText(),
isPaused: timerStatus.value == TimerStatus.paused,
);
});
}
Future<void> _stopAndroidRecordingFloatingBar() async {
if (!Platform.isAndroid) return;
_recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer = null;
await _nativePlugin.hideRecordingFloatingBar();
}
Future<void> togglePauseResumeFromFloatingBar() async {
if (!isRecording.value) return;
if (timerStatus.value == TimerStatus.running) {
await _pauseRecording();
return;
}
if (timerStatus.value == TimerStatus.paused) {
await _resumeRecording();
}
}
void _setupRecordingFloatingBarActionListener() {
_recordingFloatingActionSubscription?.cancel();
if (!Platform.isAndroid) return;
_recordingFloatingActionSubscription =
_nativePlugin.recordingFloatingActionStream.listen((action) async {
if (action.isEmpty) return;
switch (action) {
case 'togglePause':
await togglePauseResumeFromFloatingBar();
return;
case 'openMeetingRecord':
_floatingBarManuallyClosed = false;
if (Get.currentRoute == Routes.meetingRecord) return;
Get.toNamed(
Routes.meetingRecord,
arguments: {'audioTypes': audioType.value},
);
return;
case 'closeFloatingBar':
_floatingBarManuallyClosed = true;
await _stopAndroidRecordingFloatingBar();
return;
default:
return;
}
});
}
// 服务
final AsrService _asrService = Get.find<AsrService>();
final BleManager _bleManager = Get.find<BleManager>();
@ -40,9 +225,11 @@ class MeetingRecordController extends GetxController
final RxBool showTextContent = false.obs;
final RxBool isRecording = false.obs;
//final RxBool isVoiceRecognitionActive = false.obs;
final RxList<double> waveData = <double>[].obs;
final RxDouble rms = 0.0.obs;
final RecorderController waveformController = RecorderController();
// Transcription content
// 转写内容
final RxString intermediateContent = ''.obs;
final RxString finalContent = ''.obs;
@ -54,56 +241,42 @@ class MeetingRecordController extends GetxController
late FocusNode titleFocusNode;
final Future<Directory> appDocDir = getApplicationDocumentsDirectory();
// Timing variables
// 计时变量
Timer? _timer;
int _elapsedMilliseconds = 0;
int? _lastStartTime;
// 移除了未使用的变量和TabController
StreamSubscription<Uint8List>? _audioDataSubscription;
// 简化的音波数据管理
final int frameIntervalMs = 300; // 300ms间隔,更稳定的帧率
int maxVisibleBars = 60; // 最大可见波形条数量
Timer? _audioFrameTimer; // 音频帧定时器
double _currentFrameAmplitude = 0.0; // 当前帧累积振幅
int _currentFrameSamples = 0; // 当前帧样本数量
// 性能优化:音频处理采样控制
int _audioProcessCounter = 0;
static const int _audioProcessInterval = 1; // 每1个音频包处理1次
// 波形流动动画控制器
late AnimationController waveformAnimationController;
late Animation<double> waveformAnimation;
// 数据更新控制器 - 统一管理数据更新和动画
late AnimationController _dataUpdateController;
late Animation<double> _dataUpdateAnimation;
// 设置最大可见波形条数量(根据屏幕宽度)
void setMaxVisibleBars(double screenWidth) {
const barWidth = 3.0; // 波形条宽度
const barSpacing = 1.0; // 波形条间距
maxVisibleBars = (screenWidth / (barWidth + barSpacing)).floor();
}
double _smoothedRms = 0.0;
double _latestRms = 0.0;
Timer? _waveformTimer;
static const Duration _waveformTick = Duration(milliseconds: 40);
static const int _maxWaveSamples = 1200;
@override
void onInit() {
super.onInit();
_initializeComponents();
_setupFromArguments();
_initializeWaveformAnimation();
_setupRecordingFloatingBarActionListener();
//generateWaveData();
}
@override
void onClose() {
_cleanupResources();
// 清理两个AnimationController
waveformAnimationController.dispose();
_dataUpdateController.dispose();
super.onClose();
}
void applyRouteArgumentsIfIdle() {
if (isEditingTitle.value) return;
if (isRecording.value) return;
if (timerStatus.value != TimerStatus.stopped) return;
_setupFromArguments();
titleEditingController.text = newName.value;
}
// 请求录音权限
Future<bool> _requestRecordPermission() async {
try {
@ -127,69 +300,19 @@ class MeetingRecordController extends GetxController
}
}
/// Initializes UI controllers and focus nodes
/// 初始化 UI 控制器和焦点节点
void _initializeComponents() {
titleEditingController = TextEditingController(text: fileName.value);
titleFocusNode = FocusNode();
titleFocusNode.addListener(_handleTitleFocusChange);
}
/// 初始化波形流动动画和数据更新控制器
void _initializeWaveformAnimation() {
// 1. 数据更新控制器 - 控制数据提交频率
_dataUpdateController = AnimationController(
duration: Duration(milliseconds: frameIntervalMs), // 250ms 一次数据更新
vsync: this,
);
_dataUpdateAnimation =
Tween<double>(begin: 0.0, end: 1.0).animate(_dataUpdateController);
// 监听数据更新动画,每个周期结束时提交数据
_dataUpdateAnimation.addListener(() {
if (!isRecording.value) return;
// 每个动画周期结束时,才提交一次数据
if (_dataUpdateController.value >= 1.0) {
_commitAudioFrame();
// 重置动画,继续循环
_dataUpdateController.reset();
if (isRecording.value) {
_dataUpdateController.forward();
}
}
});
// 2. 波形流动动画控制器 - 控制视觉流动效果
waveformAnimationController = AnimationController(
duration: Duration(milliseconds: frameIntervalMs), // 与数据更新同步
vsync: this,
);
waveformAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: waveformAnimationController,
curve: Curves.linear, // 线性动画确保匀速流动
));
// 监听动画完成,实现连续循环
waveformAnimationController.addStatusListener((status) {
if (status == AnimationStatus.completed && isRecording.value) {
waveformAnimationController.reset();
waveformAnimationController.forward();
}
});
}
/// Sets up initial state from navigation arguments
/// 根据路由参数设置初始状态
void _setupFromArguments() {
final Map<String, dynamic> args = Get.arguments ?? {};
audioType.value = args['audioTypes'] ?? 0;
// Set default filename based on audio type
// 根据音频类型设置默认文件名
switch (audioType.value) {
case 0:
fileName.value = "liveRecording".tr; //对应中文:现场录音
@ -204,11 +327,13 @@ class MeetingRecordController extends GetxController
newName.value = fileName.value;
}
/// Cleans up resources when controller is disposed
/// 控制器销毁时清理资源
void _cleanupResources() {
_timer?.cancel();
_audioFrameTimer?.cancel(); // 清理音频帧定时器
// AnimationController在onClose中统一清理
_stopAndroidRecordingFloatingBar();
_recordingFloatingActionSubscription?.cancel();
_recordingFloatingActionSubscription = null;
_stopWaveformTimer();
_recognitionSubscription?.cancel();
_recognitionSubscription = null;
_audioDataSubscription?.cancel();
@ -218,18 +343,25 @@ class MeetingRecordController extends GetxController
if (audioType.value != 0) {
_bleManager.closeCodec();
}
_asrService.stopRecord(true);
if (isRecording.value) {
_asrService.stopRecord(true);
}
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
waveformController.reset();
waveformController.dispose();
}
/// Handles focus changes in title text field
/// 处理标题输入框焦点变化
void _handleTitleFocusChange() {
if (!titleFocusNode.hasFocus && isEditingTitle.value) {
saveTitleChange();
}
}
/// Starts title editing mode
/// 开始标题编辑模式
void startTitleEditing() {
isEditingTitle.value = true;
titleEditingController.text = fileName.value;
@ -239,7 +371,7 @@ class MeetingRecordController extends GetxController
});
}
/// Saves new title and exits editing mode
/// 保存新标题后退出编辑状态
Future<void> saveTitleChange() async {
isEditingTitle.value = true;
if (isEditingTitle.value) {
@ -249,12 +381,12 @@ class MeetingRecordController extends GetxController
}
}
/// Toggles visibility of transcribed text content
/// 切换转写文本内容的显示状态
Future<void> toggleTextDisplay() async {
showTextContent.toggle();
}
/// Toggles important mark status
/// 切换重要标记状态
void toggleMarked() => isMarked.toggle();
/// 格式化时间为 MM:SS.S 显示格式
@ -270,7 +402,7 @@ class MeetingRecordController extends GetxController
'.${centiseconds.toString()}';
}
/// Starts/resumes recording based on current state
/// 根据当前状态开始或恢复录音
Future<void> toggleRecording() async {
// 等待权限请求完成并获取结果
final bool granted = await _requestRecordPermission();
@ -281,6 +413,8 @@ class MeetingRecordController extends GetxController
return;
}
await _requestNotificationPermissionIfNeeded();
// 根据当前计时器状态执行相应操作
switch (timerStatus.value) {
case TimerStatus.stopped:
@ -295,7 +429,7 @@ class MeetingRecordController extends GetxController
}
}
/// Starts recording session
/// 开始录音会话
Future<void> _startRecording() async {
// Prepare storage directory
final appDir = await appDocDir;
@ -321,28 +455,24 @@ class MeetingRecordController extends GetxController
// 1. 实时音频可视化
// 2. 音频质量检测
// 3. 发送到其他服务
Logger.d("AudioData", "接收到音频数据: ${audioData.length} 字节");
// 处理音频数据的逻辑
_processAudioData(audioData);
});
isRecording.value = true;
// Update state
// 更新录音状态
fileName.value = fullFileName;
newName.value = fullFileName;
titleEditingController.text = fullFileName;
// 启动波形动画和音频帧定时器
_startWaveformAnimation();
_startAudioFrameTimer();
// Start hardware components
_startHardwareServices();
_resetWaveform();
_startWaveformTimer();
startTimer();
}
/// Activates appropriate hardware service based on audio type
/// 根据音频类型启动对应的硬件服务
void _startHardwareServices() {
switch (audioType.value) {
case 0:
@ -359,58 +489,62 @@ class MeetingRecordController extends GetxController
}
}
/// Pauses current recording session
/// 暂停当前录音会话
Future<void> _pauseRecording() async {
await _asrService.pauseRecord();
_stopWaveformAnimation(); // 暂停时停止动画
_stopAudioFrameTimer(); // 停止音频帧定时器
_stopWaveformTimer();
rms.value = 0.0;
pauseTimer();
}
/// Resumes paused recording session
/// 恢复已暂停的录音会话
Future<void> _resumeRecording() async {
await _asrService.resumeRecord();
_startWaveformAnimation(); // 恢复时启动动画
_startAudioFrameTimer(); // 重启音频帧定时器
_startWaveformTimer();
startTimer();
}
/// 处理原始Uint8List音频数据 - 只累积,不更新UI,性能优化
/// 处理原始Uint8List音频数据
void _processAudioData(Uint8List audioData) {
if (!isRecording.value) return;
// 性能优化:不是每个音频包都处理,减少CPU占用
_audioProcessCounter++;
if (_audioProcessCounter % _audioProcessInterval != 0) {
return; // 跳过这次处理
final next = _calculateAmplitude(audioData);
if (_smoothedRms == 0.0) {
_smoothedRms = next;
} else {
_smoothedRms = _smoothedRms * 0.8 + next * 0.2;
}
rms.value = _smoothedRms;
_latestRms = _smoothedRms;
}
// 将音频数据转换为振幅值并累积到当前帧
final amplitude = _calculateAmplitude(audioData);
_currentFrameAmplitude += amplitude;
_currentFrameSamples++;
void _resetWaveform() {
waveformController.reset();
waveformController.updateFrequency = _waveformTick;
}
/// 提交音频帧数据到波形显示
void _commitAudioFrame() {
// 计算当前帧的平均振幅
double frameAmplitude = 0.0;
if (_currentFrameSamples > 0) {
frameAmplitude = _currentFrameAmplitude / _currentFrameSamples;
}
void _startWaveformTimer() {
_waveformTimer?.cancel();
_waveformTimer = Timer.periodic(_waveformTick, (_) {
if (!isRecording.value) return;
if (timerStatus.value != TimerStatus.running) return;
_appendWaveSample(_latestRms);
});
}
// 转换为显示数据
final newAmplitude = frameAmplitude * 800.0;
void _stopWaveformTimer() {
_waveformTimer?.cancel();
_waveformTimer = null;
}
// 更新波形数据
if (waveData.length >= maxVisibleBars) {
waveData.removeAt(0);
void _appendWaveSample(double value) {
final normalized = value.clamp(0.0, 1.0);
final eased = sqrt(normalized);
final data = waveformController.waveData;
data.add(eased);
if (data.length > _maxWaveSamples) {
data.removeRange(0, data.length - _maxWaveSamples);
}
waveData.add(newAmplitude);
// 重置当前帧数据
_currentFrameAmplitude = 0.0;
_currentFrameSamples = 0;
waveformController.notifyListeners();
}
/// 计算音频数据的振幅
@ -436,55 +570,18 @@ class MeetingRecordController extends GetxController
return (rms / 32768.0).clamp(0.0, 1.0);
}
/// 启动数据更新控制器 - 替代Timer
void _startAudioFrameTimer() {
_audioFrameTimer?.cancel(); // 清理旧的Timer(如果有)
_audioFrameTimer = null;
// 启动数据更新控制器
_dataUpdateController.forward();
}
/// 停止数据更新控制器
void _stopAudioFrameTimer() {
_audioFrameTimer?.cancel();
_audioFrameTimer = null;
// 停止数据更新控制器
_dataUpdateController.stop();
_dataUpdateController.reset();
// 重置当前帧数据和计数器
_currentFrameAmplitude = 0.0;
_currentFrameSamples = 0;
_audioProcessCounter = 0; // 重置音频处理计数器
}
/// 启动波形流动动画
void _startWaveformAnimation() {
if (isRecording.value && !waveformAnimationController.isAnimating) {
waveformAnimationController.forward();
}
}
/// 停止波形动画
void _stopWaveformAnimation() {
waveformAnimationController.stop();
waveformAnimationController.reset();
}
/// Saves recording and cleans up resources
/// 保存录音并清理相关资源
Future<void> saveRecording() async {
resetTimer();
_stopWaveformAnimation(); // 停止波形动画
_stopAudioFrameTimer(); // 停止音频帧定时器
_stopWaveformTimer();
if (audioType.value != 0) {
_bleManager.closeCodec();
}
if (!isRecording.value) return;
await _asrService.stopRecord(true);
await clearBackgroundRecordingIndicator();
// Rename file if title was edited
// 如果标题被修改则重命名文件
if (fileName.value != newName.value) {
final appDir = await appDocDir;
final dir = Directory("${appDir.path}/MeetingAudio/");
@ -506,9 +603,31 @@ class MeetingRecordController extends GetxController
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
}
Future<void> discardRecording() async {
resetTimer();
_stopWaveformTimer();
if (audioType.value != 0) {
_bleManager.closeCodec();
}
if (isRecording.value) {
await _asrService.stopRecord(false);
}
await clearBackgroundRecordingIndicator();
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
}
/// Resets controller to initial state
/// 将控制器重置到初始状态
void _resetToDefaultState() {
switch (audioType.value) {
case 0:
@ -527,7 +646,7 @@ class MeetingRecordController extends GetxController
isMarked.value = false;
}
/// TIMER CONTROL SECTION ///
/// 计时器控制部分 ///
/// 启动定时器
void startTimer() {
@ -549,7 +668,7 @@ class MeetingRecordController extends GetxController
}
}
/// Pauses active timer
/// 暂停当前计时器
void pauseTimer() {
if (timerStatus.value == TimerStatus.running) {
timerStatus.value = TimerStatus.paused;
@ -562,7 +681,7 @@ class MeetingRecordController extends GetxController
}
}
/// Resets timer to initial state
/// 将计时器重置到初始状态
void resetTimer() {
_timer?.cancel();
_timer = null;
@ -571,15 +690,4 @@ class MeetingRecordController extends GetxController
seconds.value = 0;
timerStatus.value = TimerStatus.stopped;
}
/// UI HELPERS SECTION ///
/// Generates mock waveform data for visualization
void generateWaveData() {
waveData.value = List.generate(120, (i) {
final rand = (i % 20) / 20;
final baseHeight = (sin(i / 5) * 0.5 + 0.5) * 25;
return baseHeight + rand * 10;
});
}
}

48
lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart

@ -26,8 +26,10 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
final GetStorage _storage = GetStorage();
bool _isSpeaker = true;
String _languageText = 'chineseSimplified'.tr; // 对应中文:中文普通话(简体)
String _language = 'zh-CN';
String _formlanguageText = 'chineseSimplified'.tr; // 对应中文:中文普通话(简体)
String _formlanguage = 'zh-CN';
String _tolanguageText = 'chineseSimplified'.tr; // 对应中文:中文普通话(简体)
String _tolanguage = 'zh-CN';
final Map _icons = {
'auto_stories': Icons.auto_stories,
@ -273,11 +275,34 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
),
_row(
'recordingLanguage'.tr, // 对应中文:录音语言
value: _languageText,
value: _formlanguageText,
onTap: () {
Get.bottomSheet(
RecordLanguageBottomSheet(_formlanguage),
isScrollControlled: true,
backgroundColor: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
).then((value) {
if (value != null) {
setState(() {
_formlanguageText = value['name'];
_formlanguage = value['value'];
});
}
});
},
),
_row(
'targetLanguage'.tr, // 对应中文:目标语言
value: _tolanguageText,
isBorder: false,
onTap: () {
Get.bottomSheet(
RecordLanguageBottomSheet(_language),
RecordLanguageBottomSheet(_tolanguage),
isScrollControlled: true,
backgroundColor: Colors.grey[100],
shape: RoundedRectangleBorder(
@ -288,8 +313,8 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
).then((value) {
if (value != null) {
setState(() {
_languageText = value['name'];
_language = value['value'];
_tolanguageText = value['name'];
_tolanguage = value['value'];
});
}
});
@ -318,16 +343,13 @@ class _GenerateBottomSheetState extends State<GenerateBottomSheet> {
_storage.write("meeting_template_list", _templateList);
Get.back();
if (widget.isSummarizeAgain) {
_controller.refreshSummary(
_templateData['prompt'],
_templateData['outline'],
);
_controller.refreshSummary(_templateData['id']);
} else {
_controller.summaryCreate(
_formlanguage,
_tolanguage,
_isSpeaker,
_language,
_templateData['prompt'],
_templateData['outline'],
_templateData['id'],
);
}
},

2
lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart

@ -26,7 +26,7 @@ class _MoreBottomSheetState extends State<MoreBottomSheet> {
children: [
SizedBox(height: 10.w),
_shareItem(Icons.refresh, 'reSummarize'.tr, //重新总结
onTap: _detailsController.meetingDetails.value.tasktype >= 3
onTap: _detailsController.meetingDetails.value.tasktype >= 5
? () {
Get.back();
Get.bottomSheet(

18
lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart

@ -30,10 +30,12 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
Colors.red,
onTap: () async {
Get.back();
await Get.toNamed(Routes.meetingRecord, arguments: {
final result = await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 0,
});
controller.addMeetingAudio();
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
},
),
if (controller.meetingController.type == 1)
@ -45,10 +47,12 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
onTap: controller.bleManager.isConnected
? () async {
Get.back();
await Get.toNamed(Routes.meetingRecord, arguments: {
final result = await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 1,
});
controller.addMeetingAudio();
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
}
: null,
),
@ -61,10 +65,12 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
onTap: controller.bleManager.isConnected
? () async {
Get.back();
await Get.toNamed(Routes.meetingRecord, arguments: {
final result = await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 2,
});
controller.addMeetingAudio();
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
}
: null,
),

23
lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart

@ -13,7 +13,6 @@ import 'package:share_plus/share_plus.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../../core/utils/synchrodata.dart';
import '../../../../data/services/version_update_service.dart';
import '../../../../data/services/network/dio_manager.dart';
import '../../controllers/meeting_details_controller.dart';
@ -86,17 +85,9 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
isBorder: false,
onTap: () async {
Get.back();
Synchrodata.uploadOperationRecords();
final GetStorage storage = GetStorage();
String token = storage.read("logintoken") ?? '';
String userId = '';
Map? userInfo = storage.read("user_info");
if (userInfo != null) {
userId = userInfo['user']['uid'];
}
String id =
'${userId}_${_controller.meetingData.value.id}';
int id = _controller.meetingData.value.id;
// final thumbnail = await getAssetThumbnailFile(
// 'assets/images/mine-background.png',
@ -126,7 +117,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
]),
_box([
_shareItem(Icons.copy, 'copyTranscript'.tr, // 对应中文:复制转写
onTap: _controller.meetingDetails.value.tasktype >= 2
onTap: _controller.meetingDetails.value.tasktype >= 3
? () {
Get.back();
_copy(_getAllText());
@ -134,7 +125,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
: null),
_shareItem(Icons.copy, 'copySummary'.tr, // 对应中文:复制总结
isBorder: false,
onTap: _controller.meetingDetails.value.tasktype >= 3
onTap: _controller.meetingDetails.value.tasktype >= 5
? () {
Get.back();
_copy(_markdownToPlainText(
@ -151,7 +142,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
'shareMeetingAudio'.tr); // 对应中文:分享会议音频
}),
_shareItem(Icons.message, 'exportTranscript'.tr, // 对应中文:导出转写
onTap: _controller.meetingDetails.value.tasktype >= 2
onTap: _controller.meetingDetails.value.tasktype >= 3
? () async {
Get.back();
final tempDir = await getTemporaryDirectory();
@ -164,7 +155,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
}
: null),
_shareItem(Icons.article, 'exportSummary'.tr, // 对应中文:导出总结
onTap: _controller.meetingDetails.value.tasktype >= 3
onTap: _controller.meetingDetails.value.tasktype >= 5
? () async {
Get.back();
Get.bottomSheet(
@ -214,7 +205,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
_shareItem(
Icons.save_alt,
'保存思维导图',
onTap: _controller.meetingDetails.value.tasktype >= 3
onTap: _controller.meetingDetails.value.tasktype >= 5
? () {
Get.back();
final mindMapController =
@ -242,7 +233,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
Icons.lan,
'shareMindMap'.tr, // 对应中文:分享思维导图
isBorder: false,
onTap: _controller.meetingDetails.value.tasktype >= 3
onTap: _controller.meetingDetails.value.tasktype >= 5
? () {
Get.back();
final mindMapController =

11
lib/modules/meeting/views/bottomSheet/speaker_all_bottom_sheet.dart

@ -8,8 +8,8 @@ import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:lottie/lottie.dart';
import '../../../../core/utils/synchrodata.dart';
import '../../../../data/services/db/sqflite_api.dart';
import '../../../../data/services/network/api.dart';
import '../../controllers/meeting_details_controller.dart';
class SpeakerAllBottomSheet extends StatefulWidget {
@ -560,10 +560,11 @@ class _SpeakerAllBottomSheetState extends State<SpeakerAllBottomSheet> {
bool isEditSummary = false;
String summaryText = _controller.meetingDetails.value.summary;
_controller.textList.value = newTextList;
Synchrodata.saveOperationRecord('update', {
'original': jsonEncode(_controller.textList),
Map modifyData = {
'id': _controller.meetingData.value.id,
});
'translate': jsonEncode(_controller.textList),
'personnel': personnel,
};
for (var element in personnelList) {
if (summaryText.contains('[${element['original']}]')) {
summaryText = summaryText.replaceAll(
@ -576,11 +577,13 @@ class _SpeakerAllBottomSheetState extends State<SpeakerAllBottomSheet> {
'summary': summaryText,
'personnel': personnel,
});
modifyData.addAll({'summary': summaryText});
_controller.meetingDetails.value.summary = summaryText;
_controller.summaryNumberModifications.value++;
} else {
SqfliteApi.editMeeting(_controller.id, {'personnel': personnel});
}
await Api.modifyRecordEchomeet(modifyData);
_controller.meetingDetails.value.personnel = personnel;
_controller.personnelNumberModifications.value++;
_controller.meetingDetails.refresh();

15
lib/modules/meeting/views/bottomSheet/speaker_bottom_sheet.dart

@ -6,8 +6,8 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../../../core/utils/synchrodata.dart';
import '../../../../data/services/db/sqflite_api.dart';
import '../../../../data/services/network/api.dart';
import '../../controllers/meeting_details_controller.dart';
class SpeakerBottomSheet extends StatefulWidget {
@ -337,9 +337,9 @@ class _SpeakerBottomSheetState extends State<SpeakerBottomSheet> {
dataMap['speaker'] = _speakerController.text;
_controller.textList[index] = dataMap;
}
Synchrodata.saveOperationRecord('update', {
'original': jsonEncode(_controller.textList),
await Api.modifyRecordEchomeet({
'id': _controller.meetingData.value.id,
'translate': jsonEncode(_controller.textList),
});
_addHistorySpeaker();
Get.back();
@ -371,10 +371,11 @@ class _SpeakerBottomSheetState extends State<SpeakerBottomSheet> {
var result = await SqfliteApi.editMeetingSpeaker(editSpeakerList);
if (result != null) {
_controller.textList.value = newTextList;
Synchrodata.saveOperationRecord('update', {
'original': jsonEncode(_controller.textList),
Map modifyData = {
'id': _controller.meetingData.value.id,
});
'translate': jsonEncode(_controller.textList),
'personnel': personnel,
};
if (_controller.meetingDetails.value.summary
.contains('[${widget.speakerName}]')) {
String summaryText = _controller.meetingDetails.value.summary
@ -384,11 +385,13 @@ class _SpeakerBottomSheetState extends State<SpeakerBottomSheet> {
'summary': summaryText,
'personnel': personnel,
});
modifyData.addAll({'summary': summaryText});
_controller.meetingDetails.value.summary = summaryText;
_controller.summaryNumberModifications.value++;
} else {
SqfliteApi.editMeeting(_controller.id, {'personnel': personnel});
}
await Api.modifyRecordEchomeet(modifyData);
_controller.meetingDetails.value.personnel = personnel;
_addHistorySpeaker();
Get.back();

44
lib/modules/meeting/views/meeting_details_view.dart

@ -331,35 +331,43 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
Obx(
() => controller.isLoading.value
? const SizedBox()
: controller.meetingDetails.value.tasktype == 1 ||
controller.meetingDetails.value.tasktype == 2
? _meetingWait('analyzing'.tr) // 对应中文:正在分析...
: controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 5
? _meetingWait(
controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype <
3
? 'transcribing'.tr // 对应中文:正在转写...
: 'analyzing'.tr) // 对应中文:正在分析...
: const OverviewTab(),
),
Obx(
() => controller.meetingDetails.value.tasktype == 1
() => controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 3
? _meetingWait('transcribing'.tr) // 对应中文:正在转写...
: const SpeechTab(),
),
Obx(
() => controller.meetingDetails.value.tasktype == 1 ||
controller.meetingDetails.value.tasktype == 2
() => controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 5
? _meetingWait(
controller.meetingDetails.value.tasktype == 1
controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 3
? 'transcribing'.tr // 对应中文:正在转写...
: 'summarizing'.tr) // 对应中文:正在总结...
: 'analyzing'.tr) // 对应中文:正在分析...
: const SummaryTab(),
),
Obx(
() => controller.meetingDetails.value.tasktype >= 3
? const MindMapTab()
: controller.meetingDetails.value.tasktype == 0
? const SizedBox()
: _meetingWait(
controller.meetingDetails.value.tasktype == 1
? 'transcribing'.tr // 对应中文:正在转写...
: 'summarizing'.tr), // 对应中文:正在总结...
),
Obx(() => controller.meetingDetails.value.tasktype >= 5
? const MindMapTab()
: controller.meetingDetails.value.tasktype == 0
? const SizedBox()
: _meetingWait(controller
.meetingDetails.value.tasktype >
0 &&
controller.meetingDetails.value.tasktype < 3
? 'transcribing'.tr // 对应中文:正在转写...
: 'analyzing'.tr) // 对应中文:正在分析...
),
],
),
),

57
lib/modules/meeting/views/meeting_home_view.dart

@ -247,6 +247,30 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
}
Widget _item(BuildContext context, bool isDarkMode, MeetingModel data) {
String tasktypeText = '';
switch (data.tasktype) {
case 0:
tasktypeText = '';
break;
case 1:
tasktypeText = 'transcribing'.tr; // 对应中文:正在转写...
break;
case 2:
tasktypeText = 'transcribing'.tr; // 对应中文:正在转写...
break;
case 3:
tasktypeText = 'summarizing'.tr; // 对应中文:正在总结...
break;
case 4:
tasktypeText = 'summarizing'.tr; // 对应中文:正在总结...
break;
case 5:
tasktypeText = 'summaryComplete'.tr; // 对应中文:总结完成
break;
default:
tasktypeText = '';
}
return GestureDetector(
onTap: () {
Get.toNamed(Routes.meetingDetails, arguments: {
@ -374,29 +398,16 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
)
: const SizedBox();
}),
Obx(() {
Map taskData =
_controller.taskAggregator.taskFinishList.firstWhere(
(element) => element['id'] == data.id,
orElse: () => {},
);
return taskData.isNotEmpty
? Padding(
padding: EdgeInsets.only(top: 5.w),
child: Text(
taskData['tasktype'] == 1
? 'transcribing'.tr // 对应中文:正在转写...
: taskData['tasktype'] == 2
? 'summarizing'.tr // 对应中文:正在总结...
: 'summaryComplete'.tr, // 对应中文:总结完成
style: TextStyle(
color: Colors.blue,
fontSize: 10.sp,
),
),
)
: const SizedBox();
}),
Padding(
padding: EdgeInsets.only(top: 5.w),
child: Text(
tasktypeText,
style: TextStyle(
color: Colors.blue,
fontSize: 10.sp,
),
),
),
],
),
],

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

@ -1,24 +1,236 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../controllers/meeting_record_controller.dart';
import '../../../core/theme/app_colors.dart';
enum _MeetingRecordExitAction {
saveAndEnd,
endWithoutSave,
continueInBackground,
cancel,
}
class MeetingRecordView extends GetView<MeetingRecordController> {
const MeetingRecordView({super.key});
Future<_MeetingRecordExitAction?> _showExitConfirmDialog(bool isDarkMode) {
return Get.dialog<_MeetingRecordExitAction>(
Dialog(
insetPadding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white,
child: Container(
width: 320.w,
padding: EdgeInsets.all(20.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.cardBackground : Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 56.w,
height: 56.w,
decoration: BoxDecoration(
color: (isDarkMode ? Colors.white : Colors.blue)
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(28.r),
),
child: Icon(
Icons.mic_rounded,
size: 28.sp,
color: isDarkMode ? Colors.white : Colors.blue,
),
),
SizedBox(height: 14.h),
Text(
'meetingRecordExitTitle'.tr, // 对应中文:退出录音
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(height: 8.h),
Text(
'meetingRecordExitMessage'.tr, // 对应中文:请选择离开后的操作
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
height: 1.4,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
SizedBox(height: 18.h),
SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton(
onPressed: () => Get.back(
result: _MeetingRecordExitAction.saveAndEnd,
),
style: ElevatedButton.styleFrom(
backgroundColor:
isDarkMode ? AppColors.primary : Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
elevation: 0,
),
child: Text(
'meetingRecordEndAndSave'.tr, // 对应中文:结束录音并保存
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: 10.h),
OutlinedButton(
onPressed: () => Get.back(
result: _MeetingRecordExitAction.continueInBackground,
),
style: OutlinedButton.styleFrom(
foregroundColor:
isDarkMode ? Colors.white70 : Colors.grey[800],
side: BorderSide(
color:
isDarkMode ? Colors.white30 : Colors.grey[400]!,
),
padding: EdgeInsets.symmetric(vertical: 12.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
),
child: Text(
'meetingRecordContinueInBackground'.tr, // 对应中文:后台继续录音
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(height: 10.h),
OutlinedButton(
onPressed: () => Get.back(
result: _MeetingRecordExitAction.endWithoutSave,
),
style: OutlinedButton.styleFrom(
foregroundColor:
isDarkMode ? Colors.red[300] : Colors.red[600],
side: BorderSide(
color:
isDarkMode ? Colors.red[300]! : Colors.red[300]!,
),
padding: EdgeInsets.symmetric(vertical: 12.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
),
child: Text(
'meetingRecordEndWithoutSave'.tr, // 对应中文:结束录音不保存
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(height: 10.h),
TextButton(
onPressed: () =>
Get.back(result: _MeetingRecordExitAction.cancel),
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 12.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
side: BorderSide(
color:
isDarkMode ? Colors.white24 : Colors.grey[300]!,
),
),
),
child: Text(
'cancel'.tr, // 对应中文:取消
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white70 : Colors.grey[700],
),
),
),
],
),
),
],
),
),
),
barrierDismissible: true,
);
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
controller.applyRouteArgumentsIfIdle();
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
// 在返回时执行保存操作
//controller.saveTitleChange();
//await controller.saveRecording();
if (didPop) return;
if (!controller.isRecording.value) {
Get.back();
Future.microtask(() {
if (Get.isRegistered<MeetingRecordController>()) {
Get.delete<MeetingRecordController>(force: true);
}
});
return;
}
final action = await _showExitConfirmDialog(isDarkMode);
if (action == null || action == _MeetingRecordExitAction.cancel) {
return;
}
switch (action) {
case _MeetingRecordExitAction.saveAndEnd:
await controller.saveTitleChange();
await controller.saveRecording();
Get.back(result: 'meeting_record_saved');
Future.microtask(() {
if (Get.isRegistered<MeetingRecordController>()) {
Get.delete<MeetingRecordController>(force: true);
}
});
return;
case _MeetingRecordExitAction.endWithoutSave:
await controller.discardRecording();
Get.back(result: 'meeting_record_discarded');
Future.microtask(() {
if (Get.isRegistered<MeetingRecordController>()) {
Get.delete<MeetingRecordController>(force: true);
}
});
return;
case _MeetingRecordExitAction.continueInBackground:
await controller.saveTitleChange();
await controller.showBackgroundRecordingIndicator();
Get.back(result: 'meeting_record_background');
return;
case _MeetingRecordExitAction.cancel:
return;
}
},
child: Scaffold(
@ -29,12 +241,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
onPressed: () async {
// 在返回按钮点击时执行保存操作
controller.saveTitleChange();
await controller.saveRecording();
Get.back();
},
onPressed: () => Navigator.of(context).maybePop(),
),
title: Obx(() {
if (controller.isEditingTitle.value) {
@ -108,16 +315,21 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
// 使用LayoutBuilder获取准确的屏幕宽度
final screenWidth = constraints.maxWidth;
// 初始化最大可见波形条数量
controller.setMaxVisibleBars(screenWidth);
return Column(
children: [
SizedBox(height: 24.h),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Align(
alignment: Alignment.centerLeft,
child: _buildRecordTypeBadge(isDarkMode),
),
),
SizedBox(height: 12.h),
// 实时流动波形显示区域
Container(
height: 200.h,
margin: EdgeInsets.symmetric(horizontal: 16.w),
margin: EdgeInsets.zero,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
@ -155,12 +367,49 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
borderRadius: BorderRadius.circular(12.r),
child: Stack(
children: [
// 实时流动波形绘制组件
_WaveformWidget(
controller: controller,
isDarkMode: isDarkMode,
size: Size(screenWidth - 32.w, 200.h),
),
Obx(() {
final size = Size(screenWidth, 200.h);
final waveColor =
isDarkMode ? Colors.white : Colors.black87;
final shader = ui.Gradient.linear(
const Offset(0, 0),
Offset(size.width, 0),
[
waveColor.withValues(alpha: 0.0),
waveColor.withValues(alpha: 0.25),
waveColor.withValues(alpha: 0.6),
waveColor.withValues(alpha: 0.95),
],
const [0.0, 0.55, 0.8, 1.0],
);
return Center(
child: AudioWaveforms(
size: size,
recorderController: controller.waveformController,
backgroundColor: Colors.transparent,
waveStyle: WaveStyle(
waveThickness: 1.w,
spacing: 2.w,
waveCap: StrokeCap.round,
showMiddleLine: true,
middleLineColor: Colors.redAccent.withValues(
alpha: controller.timerStatus.value ==
TimerStatus.running
? 0.9
: 0.0,
),
middleLineThickness: 1.w,
showTop: true,
showBottom: true,
extendWaveform: true,
// gradient: shader,
waveColor: waveColor,
scaleFactor: 90,
),
),
);
}),
// 录音状态指示器
Obx(() {
@ -217,7 +466,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
fontWeight: FontWeight.normal,
color: isDarkMode ? Colors.white : Colors.black87,
fontFeatures: const [
FontFeature.tabularFigures()
ui.FontFeature.tabularFigures()
], // 使用等宽数字,避免跳动
),
)),
@ -250,6 +499,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
tooltip: controller.isMarked.value
? 'unmarkFavorite'.tr
: 'markFavorite'.tr,
isDarkMode: isDarkMode,
),
// 录音按钮
_buildActionButton(
@ -265,6 +515,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
tooltip: controller.timerStatus.value == TimerStatus.running
? 'pauseRecording'.tr
: 'startRecording'.tr,
isDarkMode: isDarkMode,
),
// 完成按钮
_buildActionButton(
@ -276,6 +527,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
radius: 32.w,
tooltip: 'finishRecording'.tr,
isDarkMode: isDarkMode,
),
],
),
@ -288,11 +540,14 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
required Widget child,
required double radius,
required String tooltip,
required bool isDarkMode,
}) {
final Color bgColor = isDarkMode ? const Color(0xFF1E1E1E) : Colors.white;
return Tooltip(
message: tooltip,
child: Material(
color: Colors.white,
color: bgColor,
shape: const CircleBorder(),
elevation: 4,
child: InkWell(
@ -300,7 +555,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
customBorder: const CircleBorder(),
child: CircleAvatar(
radius: radius,
backgroundColor: Colors.white,
backgroundColor: bgColor,
child: child,
),
),
@ -308,8 +563,67 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
);
}
Widget _buildRecordTypeBadge(bool isDarkMode) {
return Obx(() {
String label;
IconData icon;
Color color;
switch (controller.audioType.value) {
case 0:
label = 'liveRecording'.tr;
icon = Icons.mic_rounded;
color = Colors.red;
break;
case 1:
label = 'audioVideoRecording'.tr;
icon = Icons.videocam_rounded;
color = Colors.green;
break;
case 2:
default:
label = 'callRecording'.tr;
icon = Icons.phone_in_talk_rounded;
color = Colors.orange;
break;
}
return Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
decoration: BoxDecoration(
color: color.withValues(alpha: isDarkMode ? 0.25 : 0.12),
borderRadius: BorderRadius.circular(999.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18.sp,
color: isDarkMode ? Colors.white : color,
),
SizedBox(width: 6.w),
Text(
label,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
],
),
);
});
}
// 底部编辑弹窗
void _bottomSheet(BuildContext context, bool isDarkMode) {
void _bottomSheet(BuildContext context, bool isDarkMode) async {
//如果当前计时器在运行,先暂停录音,避免编辑时继续录音
if (controller.timerStatus.value == TimerStatus.running) {
await controller.toggleRecording();
}
Get.bottomSheet(
Container(
padding: EdgeInsets.only(
@ -450,218 +764,3 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
);
}
}
/// 简化的实时流动波形绘制器
class RealTimeWaveformPainter extends CustomPainter {
final List<double> waveData;
final bool isDarkMode;
final bool isRecording;
final double animationValue; // 动画值,用于流动效果
RealTimeWaveformPainter({
required this.waveData,
required this.isDarkMode,
required this.isRecording,
this.animationValue = 0.0,
});
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
// 绘制中央基准线
_drawCenterLine(canvas, size, centerY);
// 只在录音状态下且有波形数据时才绘制波形
if (isRecording && waveData.isNotEmpty) {
_drawWaveform(canvas, size, centerY);
}
}
/// 绘制中央基准线
void _drawCenterLine(Canvas canvas, Size size, double centerY) {
final linePaint = Paint()
..color =
(isDarkMode ? Colors.white : Colors.black).withValues(alpha: 0.15)
..strokeWidth = 1.0;
canvas.drawLine(
Offset(0, centerY),
Offset(size.width, centerY),
linePaint,
);
}
/// 绘制简化的流动波形
void _drawWaveform(Canvas canvas, Size size, double centerY) {
const double barWidth = 3.0; // 波形条宽度
const double barSpacing = 1.0; // 波形条间距
const double totalBarWidth = barWidth + barSpacing;
// 计算可显示的波形条数量
final maxBars = (size.width / totalBarWidth).floor();
final visibleBars = waveData.length > maxBars ? maxBars : waveData.length;
// 计算流动偏移量(从右向左移动)
final flowOffset = animationValue * totalBarWidth;
// 从右到左绘制波形条
for (int i = 0; i < visibleBars; i++) {
final dataIndex = waveData.length - 1 - i;
final amplitude = waveData[dataIndex];
// 计算波形条的x位置(从右侧开始,向左排列)
final x = size.width - (i + 1) * totalBarWidth + flowOffset;
// 跳过屏幕外的波形条
if (x < -barWidth || x > size.width) continue;
_drawWaveBar(
canvas, x, centerY, amplitude, i.toDouble(), visibleBars.toDouble());
}
}
/// 绘制单个波形条
void _drawWaveBar(Canvas canvas, double x, double centerY, double amplitude,
double index, double totalBars) {
const double barWidth = 2.0;
const double maxHeight = 200.0;
const double minHeight = 4.0; // 最小基础高度
const double containerPadding = 10.0; // 容器内边距,防止超出边界
// 计算可用的最大高度(考虑容器边界)
final availableHeight = (centerY - containerPadding) * 2; // 上下对称
final effectiveMaxHeight = math.min(maxHeight, availableHeight);
// 优化波形条高度计算:确保有基础高度 + 振幅变化
final normalizedAmplitude = (amplitude / 100.0).clamp(0.0, 1.0);
final dynamicHeight =
normalizedAmplitude * (effectiveMaxHeight - minHeight);
final barHeight = minHeight + dynamicHeight;
// 计算透明度(越新的波形条越不透明)
//final opacity = (1.0 - (index / totalBars) * 0.6).clamp(0.3, 1.0);
// 创建波形条矩形
final rect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(x + barWidth / 2, centerY),
width: barWidth,
height: barHeight, // 使用优化后的高度
),
const Radius.circular(1.5),
);
// 创建渐变颜色
final Color baseColor = isDarkMode
? Colors.grey[500]! // 深灰色
: Colors.grey[600]!; // 更深灰色
final paint = Paint()
..color = baseColor
..style = PaintingStyle.fill;
canvas.drawRRect(rect, paint);
}
@override
bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) {
// 只有在以下情况才重绘
if (oldDelegate.isDarkMode != isDarkMode ||
oldDelegate.isRecording != isRecording) {
return true;
}
// 数据长度变化才重绘
if (oldDelegate.waveData.length != waveData.length) {
return true;
}
// 数据内容变化(比较最后一个值)
if (waveData.isNotEmpty &&
oldDelegate.waveData.isNotEmpty &&
(waveData.last - oldDelegate.waveData.last).abs() > 0.1) {
return true;
}
// 动画值每 0.1 变化才重绘(避免每帧都重绘)
return (animationValue - oldDelegate.animationValue).abs() > 0.1;
}
}
/// 优化的波形Widget,避免双重响应式更新
class _WaveformWidget extends StatefulWidget {
final MeetingRecordController controller;
final bool isDarkMode;
final Size size;
const _WaveformWidget({
required this.controller,
required this.isDarkMode,
required this.size,
});
@override
State<_WaveformWidget> createState() => _WaveformWidgetState();
}
class _WaveformWidgetState extends State<_WaveformWidget> {
late List<double> _waveData;
late bool _isRecording;
StreamSubscription? _waveDataSubscription;
StreamSubscription? _isRecordingSubscription;
@override
void initState() {
super.initState();
_waveData = List.from(widget.controller.waveData);
_isRecording = widget.controller.isRecording.value;
// 监听 waveData 变化
_waveDataSubscription = widget.controller.waveData.listen((data) {
if (mounted) {
setState(() {
_waveData = List.from(data);
});
}
});
// 监听 isRecording 变化
_isRecordingSubscription = widget.controller.isRecording.listen((value) {
if (mounted) {
setState(() {
_isRecording = value;
});
}
});
}
@override
void dispose() {
_waveDataSubscription?.cancel();
_isRecordingSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: widget.controller.waveformAnimation,
builder: (context, child) {
return CustomPaint(
size: widget.size,
painter: RealTimeWaveformPainter(
waveData: _waveData,
isDarkMode: widget.isDarkMode,
isRecording: _isRecording,
animationValue: widget.controller.waveformAnimation.value,
),
isComplex: true, // 告诉 Flutter 这个绘制较复杂,可缓存
willChange: false, // 数据不会频繁变化(我们已优化)
);
},
),
);
}
}

11
lib/modules/meeting/views/tabs/summary_tab.dart

@ -6,6 +6,7 @@ import 'package:get/get.dart';
import 'package:parchment/codecs.dart';
import '../../../../data/services/db/sqflite_api.dart';
import '../../../../data/services/network/api.dart';
import '../../controllers/meeting_details_controller.dart';
import '../bottomSheet/ai_bottom_sheet.dart';
import '../bottomSheet/generate_bottom_sheet.dart';
@ -133,6 +134,10 @@ class _SummaryTabState extends State<SummaryTab> {
final markdown =
const ParchmentMarkdownCodec().encode(_fleatherController!.document);
if (markdown != _controller.meetingDetails.value.summary) {
Api.modifyRecordEchomeet({
'id': _controller.id,
'summary': markdown,
});
SqfliteApi.editMeeting(
_controller.id,
{'summary': markdown},
@ -272,6 +277,12 @@ class _SummaryTabState extends State<SummaryTab> {
isEdit = true;
}
if (isEdit) {
Api.modifyRecordEchomeet({
'id': _controller.id,
'address': _controller.meetingDetails.value.address,
'personnel':
_controller.meetingDetails.value.personnel,
});
SqfliteApi.editMeeting(
_controller.id,
{

8
lib/modules/splash/controllers/splash_controller.dart

@ -10,9 +10,6 @@ import 'package:http/http.dart' as http;
import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
import '../../../data/models/user_Info.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
import '../../../data/services/meeting/meeting_upload_service.dart';
import '../../../data/services/meeting/summary_task_service.dart';
import '../../../routes/app_routes.dart';
import '../../../data/services/network/api.dart';
import '../../../data/services/version_update_service.dart';
@ -269,13 +266,8 @@ class SplashController extends GetxController {
final isAppConfigInitialized = AppConfig.isInitialized();
// 检查登录状态和配置初始化状态
if (token != null && token.isNotEmpty && isAppConfigInitialized) {
Get.find<MeetingUploadService>();
Get.find<MeetingTaskService>();
Get.find<SummaryTaskService>();
Get.offAllNamed(Routes.pairing);
} else {
_storage.remove('meeting_task_list');
_storage.remove('meeting_task_finish_list');
_storage.remove('meeting_upload_list');
_storage.remove('operation_records');
_storage.remove('operation_delids');

1
lib/routes/app_pages.dart

@ -237,6 +237,7 @@ abstract class AppPages {
name: Routes.meetingRecord,
page: () => const MeetingRecordView(),
binding: MeetingRecordBinding(),
popGesture: false,
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),

35
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt

@ -413,7 +413,7 @@ private var companionManager: BleCompanionManager? = null
// 设置服务状态
isRunning.set(true)
startForeground(NOTIFICATION_ID, createNotification())
// 初始化BLE伴侣设备管理器 - 使用单例模式获取实例
// 初始化BLE伴侣设备管理器 - 使用单例模式获取实例
companionManager = BleCompanionManager.getInstance(this)
// 初始化蓝牙监听器
initializeBluetoothListener()
@ -850,12 +850,20 @@ private var companionManager: BleCompanionManager? = null
// Android 8.0+ 需要创建通知渠道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"语音助手代理服务",
NotificationManager.IMPORTANCE_LOW
)
manager.createNotificationChannel(channel)
val existingChannel = manager.getNotificationChannel(CHANNEL_ID)
if (existingChannel == null) {
val channel = NotificationChannel(
CHANNEL_ID,
"Voice assistant agent",
NotificationManager.IMPORTANCE_LOW
)
channel.description = "Voice assistant agent"
channel.setShowBadge(false)
channel.enableLights(false)
channel.enableVibration(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
manager.createNotificationChannel(channel)
}
}
// 点击通知打开主界面
@ -873,15 +881,22 @@ private var companionManager: BleCompanionManager? = null
null
}
val appIconResId = applicationInfo.icon.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
// 构建通知
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Voitrans")
.setContentText("语音助手代理服务运行中")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentText("Voice assistant agent is running")
.setSmallIcon(appIconResId)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setShowWhen(false)
// 设置点击意图
pendingIntent?.let { builder.setContentIntent(it) }
return builder.build()
}
}
}

54
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt

@ -80,6 +80,36 @@ class IntegratedSpeechTranslationService(
// 语音映射缓存
private val voiceCache = ConcurrentHashMap<String, String>()
// TTS合成中的音频缓存
private var ttsdata: ByteArray = ByteArray(0)
/**
* 处理合成音频分片(按1280字节对齐)
* 输入的音频数据会追加到缓存;仅当累计长度达到`multiple`的整数倍时,
* 将该整数倍长度的部分通过`onSynthesisAudioGenerated`发送;余数保留在缓存等待下次。
*/
private fun processSynthesisAudioChunk(
text: String,
incoming: ByteArray,
multiple: Int = 1280
) {
if (incoming.isNotEmpty()) {
ttsdata = ttsdata + incoming
}
val sendLen = (ttsdata.size / multiple) * multiple
if (sendLen > 0) {
val chunk = ttsdata.copyOfRange(0, sendLen)
Log.d(TAG, "按${multiple}字节对齐发送音频: ${chunk.size} 字节,缓存剩余: ${ttsdata.size - sendLen}")
eventCallback?.onSynthesisAudioGenerated(text, chunk)
ttsdata = ttsdata.copyOfRange(sendLen, ttsdata.size)
} else {
Log.d(TAG, "暂未够${multiple}字节,当前缓存: ${ttsdata.size}")
}
}
/**
* 服务配置类
*/
@ -399,6 +429,7 @@ class IntegratedSpeechTranslationService(
// 合成开始事件
SynthesisStarted.addEventListener { _, _ ->
Log.d(TAG, "合成开始事件")
ttsdata = ByteArray(0)
serviceState.isSynthesizing.set(true)
eventCallback?.onStateChanged("Synthesis", true)
}
@ -406,9 +437,11 @@ class IntegratedSpeechTranslationService(
// 合成进行中事件
Synthesizing.addEventListener { _, event ->
Log.d(TAG, "合成进行中事件")
// 计算进度(简化版)
val progress = 0.5f // 实际应用中可以根据音频数据计算
// 计算进度(简化版)
val progress = 0.5f // 实际应用中可以根据音频数据计算
eventCallback?.onSynthesisProgress("", progress)
val incoming = event.result.audioData ?: ByteArray(0)
processSynthesisAudioChunk("", incoming, 1280)
}
// 合成完成事件
@ -421,6 +454,7 @@ class IntegratedSpeechTranslationService(
// 合成取消事件
SynthesisCanceled.addEventListener { _, event ->
Log.d(TAG, "合成取消事件")
ttsdata = ByteArray(0)
serviceState.isSynthesizing.set(false)
val reason = event.result.reason
eventCallback?.onError("Synthesis", "语音合成取消: $reason")
@ -605,6 +639,7 @@ class IntegratedSpeechTranslationService(
try {
Log.d(TAG, "开始语音合成: $text")
serviceState.isSynthesizing.set(true)
eventCallback?.onSynthesisStarted(text)
val ssml = generateOptimizedSsml(text)
@ -618,12 +653,13 @@ class IntegratedSpeechTranslationService(
// 获取音频数据
val audioData = result.audioData
if (audioData != null && audioData.isNotEmpty()) {
// 通过回调返回音频数据
eventCallback?.onSynthesisAudioGenerated(text, audioData)
Log.d(TAG, "音频数据大小: ${audioData.size} 字节")
}
// if (audioData != null && audioData.isNotEmpty()) {
// // 通过回调返回音频数据
// eventCallback?.onSynthesisAudioGenerated(text, audioData)
// Log.d(TAG, "音频数据大小: ${audioData.size} 字节")
// }
eventCallback?.onSynthesisAudioGenerated(text, ttsdata)
ttsdata = ByteArray(0)
return audioData
} else {
Log.e(TAG, "语音合成失败: ${result?.reason}")
@ -1733,4 +1769,4 @@ class VolcanoTranslationServiceImpl :
Log.e(TAG, "清理资源失败", e)
}
}
}
}

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

@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper
import androidx.annotation.NonNull
import com.yunqiinnovation.azure_speech.utils.FileLogger
import com.yunqiinnovation.azure_speech.tools.AudioRecordingForegroundService
import com.yunqiinnovation.azure_speech.tools.SimpleAudioReceiver
import com.yunqiinnovation.azure_speech.tools.SimpleAudioPlayer
import io.flutter.embedding.engine.plugins.FlutterPlugin
@ -581,6 +582,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
// 启用录音,并根据 acceptAudioData 参数决定是否设置音频数据回调
}
FileLogger.d(tag, "音频文件名称为: ${filePath}")
try {
AudioRecordingForegroundService.startService(context)
} catch (e: Exception) {
FileLogger.w(tag, "启动录音前台服务失败: ${e.message}")
}
azureAsrHelper.enableRecord(audioSourceType, filePath, if(acceptAudioData) {
// 创建音频数据回调,将音频数据发送到 Flutter 层
@ -631,12 +638,20 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
try {
if(azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL){
isRecord = false
recordfile?.closeFile(true)
recordfile?.closeFile(isSave)
}
else
{
azureAsrHelper.stopRecord(isSave)
}
if (!azureAsrHelper.isContinuousRecognitionActive()) {
try {
AudioRecordingForegroundService.stopService(context)
} catch (e: Exception) {
FileLogger.w(tag, "停止录音前台服务失败: ${e.message}")
}
}
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
@ -1027,10 +1042,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
override fun onSynthesisAudioGenerated(text: String, audioData: ByteArray) {
FileLogger.d(tag, "语音合成音频生成,文本=${text},音频数据大小=${audioData.size}")
// Azure TTS返回的是RIFF WAV格式,需要去除文件头
val pcmData = extractPcmFromWav(audioData)
if (pcmData.isNotEmpty()) {
FileLogger.d(tag, "提取PCM数据,大小=${pcmData.size}字节")
BleService.writeExternalAudioData(pcmData)
//val pcmData = extractPcmFromWav(audioData)
if (audioData.isNotEmpty()) {
FileLogger.d(tag, "提取PCM数据,大小=${audioData.size}字节")
BleService.writeExternalAudioData(audioData)
}
else {
FileLogger.e(tag, "提取PCM数据失败,音频数据可能不是WAV格式")

15
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt

@ -113,8 +113,10 @@ class AudioRecordingForegroundService : Service() {
* @return 通知对象
*/
private fun createNotification(): Notification {
// 创建点击通知时的意图(可以打开应用主界面)
val intent = packageManager.getLaunchIntentForPackage(packageName)
val intent = (packageManager.getLaunchIntentForPackage(packageName) ?: Intent()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra("com.saitong.voitrans.extra.NAVIGATE_TO", "meetingRecord")
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
@ -123,14 +125,15 @@ class AudioRecordingForegroundService : Service() {
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("语音助手正在运行")
.setContentText("正在录制音频,点击返回应用")
.setSmallIcon(android.R.drawable.ic_btn_speak_now) // 使用系统默认麦克风图标
.setContentTitle("Voitrans")
.setContentText("Recording audio, tap to return to app")
.setSmallIcon(applicationInfo.icon)
.setContentIntent(pendingIntent)
.setOngoing(true) // 设置为持续通知,用户无法滑动删除
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setShowWhen(false)
.setAutoCancel(false) // 点击通知后不自动取消
.build()
}
}
}

8
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -1039,10 +1039,10 @@ private class AstEventCallback: IntegratedSpeechTranslationService.ServiceEventC
os_log("语音合成音频生成,文本=%@,音频数据大小=%d", log: plugin.log, type: .info, text, audioData.count)
// Azure TTS返回的是RIFF WAV格式,需要去除文件头提取PCM数据
let pcmData = plugin.extractPcmFromWav(audioData)
if !pcmData.isEmpty {
os_log("提取PCM数据,大小=%d字节", log: plugin.log, type: .info, pcmData.count)
BleService.shared.writeExternalAudioData(data: pcmData)
// let pcmData = plugin.extractPcmFromWav(audioData)
if !audioData.isEmpty {
os_log("提取PCM数据,大小=%d字节", log: plugin.log, type: .info, audioData.count)
BleService.shared.writeExternalAudioData(data: audioData)
} else {
os_log("提取PCM数据失败,音频数据可能不是WAV格式", log: plugin.log, type: .error)
}

54
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift

@ -66,6 +66,9 @@ import os.log
// 处理队列
private let processingQueue = DispatchQueue(label: "com.azure.speech.processing", qos: .userInitiated)
// 合成中的音频缓存
private var ttsBuffer = Data()
// MARK: - Configuration Structures
/**
@ -478,6 +481,7 @@ import os.log
// 合成开始事件
synthesizer?.addSynthesisStartedEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return }
self.ttsBuffer = Data()
os_log("合成开始事件", log: self.log, type: .info)
self.serviceState.isSynthesizing = true
@ -487,7 +491,7 @@ import os.log
}
}
// 合成进行中事件 - 修改为返回音频数据
// 合成进行中事件 - 增量发送音频分片(按1280字节对齐)
synthesizer?.addSynthesizingEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return }
// 安全解包audioData
@ -495,8 +499,8 @@ import os.log
os_log("合成进行中事件,音频数据为空", log: self.log, type: .debug)
return
}
os_log("合成进行中事件,音频数据长度: %d bytes", log: self.log, type: .debug, audioData.count)
self.processSynthesisAudioChunk(text: "", incoming: audioData, multiple: 1280)
os_log("合成进行中事件,累计缓存长度: %d bytes", log: self.log, type: .debug, self.ttsBuffer.count)
}
// 合成完成事件
@ -505,16 +509,14 @@ import os.log
os_log("合成完成事件", log: self.log, type: .info)
// 安全解包audioData
guard let audioData = event.result.audioData else {
os_log("合成完成事件,音频数据为空", log: self.log, type: .debug)
return
}
os_log("合成完成事件,音频数据长度: %d bytes", log: self.log, type: .debug, audioData.count)
// 发送缓存中的剩余数据(可能非1280对齐的余量)
let remaining = self.ttsBuffer
self.ttsBuffer = Data()
DispatchQueue.main.async {
self.serviceState.isSynthesizing = false
// 立即返回当前生成的音频数据
self.eventCallback?.onSynthesisAudioGenerated(text: "", audioData: audioData)
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false)
self.serviceState.isSynthesizing = false
self.eventCallback?.onSynthesisAudioGenerated(text: "", audioData: remaining)
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false)
}
}
@ -525,6 +527,8 @@ import os.log
os_log("合成取消事件", log: self.log, type: .info)
self.serviceState.isSynthesizing = false
// 取消时清空缓存,避免脏数据
self.ttsBuffer = Data()
// 修复:移除可选链,因为event.result不是可选类型
let reason = event.result.reason
@ -542,6 +546,32 @@ import os.log
}
}
/**
* 处理合成音频分片(按multiple字节对齐,默认1280)
* - 参数 text: 正在合成的文本
* - 参数 incoming: 新到达的音频数据块
* - 参数 multiple: 对齐字节倍数(例如16000Hz/16bit/mono下常用1280)
* 将incoming追加到缓存,仅当累计长度达到multiple的整数倍时,
* 发送对齐部分;余数继续保留在缓存,等待后续数据到来或合成完成时一次性发送。
*/
private func processSynthesisAudioChunk(text: String, incoming: Data?, multiple: Int = 1280) {
guard let incoming = incoming, !incoming.isEmpty else { return }
// 追加到缓存
ttsBuffer.append(incoming)
// 计算可发送的对齐长度
let sendLen = (ttsBuffer.count / multiple) * multiple
if sendLen > 0 {
let chunk = ttsBuffer.prefix(sendLen)
// 发送对齐部分
let toSend = Data(chunk)
DispatchQueue.main.async { [weak self] in
self?.eventCallback?.onSynthesisAudioGenerated(text: text, audioData: toSend)
}
// 保留余数
ttsBuffer = Data(ttsBuffer.suffix(ttsBuffer.count - sendLen))
}
}
// MARK: - Public Methods
/**

8
local_plugins/native_plugin/android/src/main/AndroidManifest.xml

@ -1,3 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.native_plugin">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application>
<service
android:name=".RecordingFloatingService"
android:exported="false" />
</application>
</manifest>

88
local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java

@ -2,37 +2,123 @@ package com.example.native_plugin;
import androidx.annotation.NonNull;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
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;
/** NativePlugin */
public class NativePlugin implements FlutterPlugin, MethodCallHandler {
public class NativePlugin implements FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private MethodChannel channel;
private EventChannel recordingFloatingActionsChannel;
private Context applicationContext;
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
private static volatile EventChannel.EventSink recordingFloatingActionSink;
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
applicationContext = flutterPluginBinding.getApplicationContext();
channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "native_plugin");
channel.setMethodCallHandler(this);
recordingFloatingActionsChannel =
new EventChannel(flutterPluginBinding.getBinaryMessenger(), "native_plugin/recording_floating_actions");
recordingFloatingActionsChannel.setStreamHandler(this);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
if (call.method.equals("getPlatformVersion")) {
result.success("Android " + android.os.Build.VERSION.RELEASE);
} else if (call.method.equals("showRecordingFloatingBar")) {
String duration = call.argument("duration");
String recordingType = call.argument("recordingType");
Boolean isPaused = call.argument("isPaused");
startRecordingFloatingService(
RecordingFloatingService.ACTION_SHOW,
duration,
recordingType,
isPaused != null && isPaused
);
result.success(true);
} else if (call.method.equals("updateRecordingFloatingBar")) {
String duration = call.argument("duration");
String recordingType = call.argument("recordingType");
Boolean isPaused = call.argument("isPaused");
startRecordingFloatingService(
RecordingFloatingService.ACTION_UPDATE,
duration,
recordingType,
isPaused != null && isPaused
);
result.success(true);
} else if (call.method.equals("hideRecordingFloatingBar")) {
startRecordingFloatingService(RecordingFloatingService.ACTION_HIDE, null, null, false);
result.success(true);
} else {
result.notImplemented();
}
}
private void startRecordingFloatingService(
String action,
String duration,
String recordingType,
boolean isPaused
) {
if (applicationContext == null) return;
Intent intent = new Intent(applicationContext, RecordingFloatingService.class);
intent.setAction(action);
if (duration != null) {
intent.putExtra(RecordingFloatingService.EXTRA_DURATION, duration);
}
if (recordingType != null) {
intent.putExtra(RecordingFloatingService.EXTRA_RECORDING_TYPE, recordingType);
}
intent.putExtra(RecordingFloatingService.EXTRA_IS_PAUSED, isPaused);
applicationContext.startService(intent);
}
static void emitRecordingFloatingAction(String action) {
final EventChannel.EventSink sink = recordingFloatingActionSink;
if (sink == null) return;
mainHandler.post(() -> {
try {
sink.success(action);
} catch (Exception ignored) {
}
});
}
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
recordingFloatingActionSink = events;
}
@Override
public void onCancel(Object arguments) {
recordingFloatingActionSink = null;
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
channel.setMethodCallHandler(null);
if (recordingFloatingActionsChannel != null) {
recordingFloatingActionsChannel.setStreamHandler(null);
}
recordingFloatingActionsChannel = null;
applicationContext = null;
}
}

292
local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/RecordingFloatingService.java

@ -0,0 +1,292 @@
package com.example.native_plugin;
import android.app.Service;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.PixelFormat;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.os.IBinder;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowManager;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
public class RecordingFloatingService extends Service {
static final String ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR";
static final String ACTION_UPDATE = "com.example.native_plugin.action.UPDATE_RECORDING_FLOATING_BAR";
static final String ACTION_HIDE = "com.example.native_plugin.action.HIDE_RECORDING_FLOATING_BAR";
static final String EXTRA_DURATION = "duration";
static final String EXTRA_RECORDING_TYPE = "recordingType";
static final String EXTRA_IS_PAUSED = "isPaused";
private WindowManager windowManager;
private View floatingView;
private WindowManager.LayoutParams layoutParams;
private TextView durationTextView;
private TextView typeTextView;
private TextView playPauseTextView;
private boolean isPaused = false;
@Override
public void onCreate() {
super.onCreate();
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null) {
return START_NOT_STICKY;
}
String action = intent.getAction();
if (ACTION_HIDE.equals(action)) {
removeFloatingView();
stopSelf();
return START_NOT_STICKY;
}
if (ACTION_SHOW.equals(action) || ACTION_UPDATE.equals(action)) {
String duration = intent.getStringExtra(EXTRA_DURATION);
String recordingType = intent.getStringExtra(EXTRA_RECORDING_TYPE);
boolean paused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false);
showOrUpdate(duration, recordingType, paused);
}
return START_STICKY;
}
private void showOrUpdate(String duration, String recordingType, boolean paused) {
if (floatingView == null) {
createFloatingView();
addFloatingView();
}
updateContent(duration, recordingType, paused);
}
private void updateContent(String duration, String recordingType, boolean paused) {
isPaused = paused;
updatePlayPauseIcon();
if (durationTextView != null) {
durationTextView.setText(duration == null ? "" : duration);
}
if (typeTextView != null) {
String type = recordingType == null ? "" : recordingType.trim();
if (type.isEmpty()) {
typeTextView.setVisibility(View.GONE);
} else {
typeTextView.setVisibility(View.VISIBLE);
typeTextView.setText(type);
}
}
}
private void updatePlayPauseIcon() {
if (playPauseTextView == null) return;
playPauseTextView.setText(isPaused ? "▶" : "⏸");
}
private void createFloatingView() {
float density = getResources().getDisplayMetrics().density;
int touchSlop = ViewConfiguration.get(this).getScaledTouchSlop();
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.HORIZONTAL);
container.setGravity(Gravity.CENTER_VERTICAL);
int paddingH = (int) (10 * density);
int paddingV = (int) (6 * density);
container.setPadding(paddingH, paddingV, paddingH, paddingV);
GradientDrawable bg = new GradientDrawable();
bg.setColor(Color.parseColor("#1A1A1A"));
bg.setCornerRadius(999 * density);
container.setBackground(bg);
playPauseTextView = new TextView(this);
int playSize = (int) (26 * density);
LinearLayout.LayoutParams playLp = new LinearLayout.LayoutParams(playSize, playSize);
playLp.rightMargin = (int) (10 * density);
playPauseTextView.setLayoutParams(playLp);
playPauseTextView.setTextColor(Color.WHITE);
playPauseTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
playPauseTextView.setGravity(Gravity.CENTER);
GradientDrawable playBg = new GradientDrawable();
playBg.setColor(Color.parseColor("#FF3B30"));
playBg.setCornerRadius(999 * density);
playPauseTextView.setBackground(playBg);
playPauseTextView.setOnClickListener(v -> {
isPaused = !isPaused;
updatePlayPauseIcon();
NativePlugin.emitRecordingFloatingAction("togglePause");
});
updatePlayPauseIcon();
container.addView(playPauseTextView);
LinearLayout middleArea = new LinearLayout(this);
middleArea.setOrientation(LinearLayout.HORIZONTAL);
middleArea.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams middleLp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
middleLp.rightMargin = (int) (10 * density);
middleArea.setLayoutParams(middleLp);
middleArea.setClickable(true);
middleArea.setFocusable(true);
durationTextView = new TextView(this);
durationTextView.setTextColor(Color.WHITE);
durationTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
LinearLayout.LayoutParams durationLp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
durationLp.rightMargin = (int) (8 * density);
durationTextView.setLayoutParams(durationLp);
durationTextView.setText("00:00");
middleArea.addView(durationTextView);
typeTextView = new TextView(this);
typeTextView.setTextColor(Color.parseColor("#E6E6E6"));
typeTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12);
LinearLayout.LayoutParams typeLp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
typeTextView.setLayoutParams(typeLp);
typeTextView.setVisibility(View.GONE);
middleArea.addView(typeTextView);
middleArea.setOnClickListener(v -> {
bringAppToFront();
NativePlugin.emitRecordingFloatingAction("openMeetingRecord");
});
middleArea.setOnTouchListener(new View.OnTouchListener() {
private int initialY;
private float initialTouchY;
private boolean dragging;
@Override
public boolean onTouch(View v, MotionEvent event) {
if (layoutParams == null || windowManager == null) return false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
initialY = layoutParams.y;
initialTouchY = event.getRawY();
dragging = false;
return true;
case MotionEvent.ACTION_MOVE:
int dy = (int) (event.getRawY() - initialTouchY);
if (!dragging && Math.abs(dy) > touchSlop) {
dragging = true;
}
if (!dragging) return true;
int newY = initialY + dy;
int screenHeight = getResources().getDisplayMetrics().heightPixels;
int viewHeight = floatingView == null ? 0 : floatingView.getHeight();
int maxY = Math.max(0, screenHeight - viewHeight - (int) (24 * density));
if (newY < 0) newY = 0;
if (newY > maxY) newY = maxY;
layoutParams.y = newY;
windowManager.updateViewLayout(floatingView, layoutParams);
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (!dragging) {
v.performClick();
}
return true;
default:
return false;
}
}
});
container.addView(middleArea);
TextView close = new TextView(this);
int closeSize = (int) (22 * density);
LinearLayout.LayoutParams closeLp = new LinearLayout.LayoutParams(closeSize, closeSize);
close.setLayoutParams(closeLp);
close.setText("×");
close.setTextColor(Color.WHITE);
close.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
close.setGravity(Gravity.CENTER);
close.setOnClickListener(v -> {
removeFloatingView();
stopSelf();
NativePlugin.emitRecordingFloatingAction("closeFloatingBar");
});
container.addView(close);
floatingView = container;
}
private void addFloatingView() {
if (windowManager == null || floatingView == null) return;
layoutParams = new WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
);
layoutParams.gravity = Gravity.TOP | Gravity.END;
float density = getResources().getDisplayMetrics().density;
layoutParams.x = (int) (6 * density);
layoutParams.y = (int) (48 * density);
windowManager.addView(floatingView, layoutParams);
}
private void removeFloatingView() {
if (windowManager != null && floatingView != null) {
try {
windowManager.removeView(floatingView);
} catch (Exception ignored) {
}
floatingView = null;
layoutParams = null;
durationTextView = null;
typeTextView = null;
playPauseTextView = null;
}
}
private void bringAppToFront() {
try {
Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launchIntent == null) return;
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(launchIntent);
} catch (Exception ignored) {
}
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
removeFloatingView();
super.onDestroy();
}
}

31
local_plugins/native_plugin/lib/native_plugin.dart

@ -16,4 +16,35 @@ class NativePlugin {
Future<void> disposePipContentView(int viewId) {
return NativePluginPlatform.instance.disposePipContentView(viewId);
}
Future<void> showRecordingFloatingBar({
required String duration,
String? recordingType,
bool isPaused = false,
}) {
return NativePluginPlatform.instance.showRecordingFloatingBar(
duration: duration,
recordingType: recordingType,
isPaused: isPaused,
);
}
Future<void> updateRecordingFloatingBar({
required String duration,
String? recordingType,
bool isPaused = false,
}) {
return NativePluginPlatform.instance.updateRecordingFloatingBar(
duration: duration,
recordingType: recordingType,
isPaused: isPaused,
);
}
Future<void> hideRecordingFloatingBar() {
return NativePluginPlatform.instance.hideRecordingFloatingBar();
}
Stream<String> get recordingFloatingActionStream =>
NativePluginPlatform.instance.recordingFloatingActionStream;
}

43
local_plugins/native_plugin/lib/native_plugin_method_channel.dart

@ -9,6 +9,18 @@ class MethodChannelNativePlugin extends NativePluginPlatform {
@visibleForTesting
final methodChannel = const MethodChannel('native_plugin');
final _recordingFloatingActionsChannel = const EventChannel(
'native_plugin/recording_floating_actions',
);
@override
Stream<String> get recordingFloatingActionStream {
if (kIsWeb) return Stream<String>.empty();
return _recordingFloatingActionsChannel.receiveBroadcastStream().map(
(event) => event?.toString() ?? '',
);
}
@override
Future<String?> getPlatformVersion() async {
final version = await methodChannel.invokeMethod<String>(
@ -34,4 +46,35 @@ class MethodChannelNativePlugin extends NativePluginPlatform {
Future<void> disposePipContentView(int viewId) async {
await methodChannel.invokeMethod<void>('disposePipContentView', viewId);
}
@override
Future<void> showRecordingFloatingBar({
required String duration,
String? recordingType,
bool isPaused = false,
}) async {
await methodChannel.invokeMethod<void>('showRecordingFloatingBar', {
'duration': duration,
'recordingType': recordingType,
'isPaused': isPaused,
});
}
@override
Future<void> updateRecordingFloatingBar({
required String duration,
String? recordingType,
bool isPaused = false,
}) async {
await methodChannel.invokeMethod<void>('updateRecordingFloatingBar', {
'duration': duration,
'recordingType': recordingType,
'isPaused': isPaused,
});
}
@override
Future<void> hideRecordingFloatingBar() async {
await methodChannel.invokeMethod<void>('hideRecordingFloatingBar');
}
}

28
local_plugins/native_plugin/lib/native_plugin_platform_interface.dart

@ -44,4 +44,32 @@ abstract class NativePluginPlatform extends PlatformInterface {
'disposePipContentView() has not been implemented.',
);
}
Future<void> showRecordingFloatingBar({
required String duration,
String? recordingType,
bool isPaused = false,
}) {
throw UnimplementedError(
'showRecordingFloatingBar() has not been implemented.',
);
}
Future<void> updateRecordingFloatingBar({
required String duration,
String? recordingType,
bool isPaused = false,
}) {
throw UnimplementedError(
'updateRecordingFloatingBar() has not been implemented.',
);
}
Future<void> hideRecordingFloatingBar() {
throw UnimplementedError(
'hideRecordingFloatingBar() has not been implemented.',
);
}
Stream<String> get recordingFloatingActionStream => Stream<String>.empty();
}

Loading…
Cancel
Save