From 4726e49834beefb9ad175a3a7f30b839b83e9fb0 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Wed, 27 May 2026 11:26:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E9=80=9A=E8=AF=9D?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E5=92=8C=E4=BC=9A=E8=AE=AE=E6=9A=A8=E9=87=8C?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=80=BB=E7=BB=93=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/models/appconfig.dart | 17 ++- lib/data/services/ble_manager.dart | 25 ---- lib/data/services/db/sqflite_api.dart | 8 +- .../meeting/meeting_task_service.dart | 127 +++++++++++++++++- lib/main.dart | 4 + .../controllers/devices_controller.dart | 4 +- .../meeting_details_controller.dart | 29 ++-- .../meeting/views/tabs/overview_tab.dart | 22 ++- .../meeting/views/tabs/summary_tab.dart | 22 ++- .../controllers/pairing_controller.dart | 53 ++++++-- .../azure_speech/tools/AstStereoRecorder.kt | 36 ++++- .../Sources/tools/AstStereoRecorder.swift | 33 ++++- 12 files changed, 313 insertions(+), 67 deletions(-) diff --git a/lib/data/models/appconfig.dart b/lib/data/models/appconfig.dart index 8376d9cd3..708c49382 100644 --- a/lib/data/models/appconfig.dart +++ b/lib/data/models/appconfig.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import '../../../data/models/appconfig_model.dart'; @@ -8,7 +9,9 @@ class AppConfig { // 是否隐藏"AI全能助手"模块(响应式)。 // true => 首页/发现页切换为精简布局,新手引导跳过 AI 引导步骤 // false => 保持原有布局 - // 数据来源:连接的 BLE 设备对应 DBProduct.hideaiassistant,由 BleManager 同步。 + // 数据来源:绑定设备时按 DBProduct.hideaiassistant 写入本地缓存, + // 之后只在「绑定新设备 / 解绑设备」时重写。与 BLE 是否连接无关。 + static const String hideAiStorageKey = 'hide_ai_assistant'; static final RxBool hideAiAssistant = false.obs; static late UserGetAppConfigResp config; @@ -61,4 +64,16 @@ class AppConfig { .firstWhere((product) => product.devicename.contains(name)); return foundProduct; } + + // 从本地缓存恢复 hideAiAssistant。在 App 启动(GetStorage.init 之后)调用一次。 + static void loadHideAiAssistantFromCache() { + final cached = GetStorage().read(hideAiStorageKey); + hideAiAssistant.value = cached ?? false; + } + + // 设置 hideAiAssistant 并写入本地缓存。绑定/解绑时调用。 + static Future setHideAiAssistant(bool hide) async { + hideAiAssistant.value = hide; + await GetStorage().write(hideAiStorageKey, hide); + } } diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index cb491e0eb..2471a5758 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -261,31 +261,6 @@ class BleManager extends GetxService { // 设置BLE事件监听 _setupBleListener(); - - // 同步 AI 助手隐藏开关:deviceInfo 拿到名字后按 DBProduct 配置写入; - // 断连时 deviceInfo 会被置 null,统一在断连分支复位。 - ever(deviceInfo, _syncHideAiAssistant); - } - - /// 按当前连接设备同步 AppConfig.hideAiAssistant - void _syncHideAiAssistant(DeviceInfo? info) { - if (info == null || info.name.isEmpty) { - if (AppConfig.hideAiAssistant.value) { - AppConfig.hideAiAssistant.value = false; - } - return; - } - try { - final product = AppConfig.getproduct(info.name); - final hide = product?.hideaiassistant ?? false; - if (AppConfig.hideAiAssistant.value != hide) { - AppConfig.hideAiAssistant.value = hide; - Logger.i('BleManager', - 'hideAiAssistant 同步: device=${info.name} hide=$hide'); - } - } catch (e) { - Logger.e('BleManager', 'hideAiAssistant 同步失败: $e'); - } } /// 释放资源 diff --git a/lib/data/services/db/sqflite_api.dart b/lib/data/services/db/sqflite_api.dart index 972245474..dfda2219a 100644 --- a/lib/data/services/db/sqflite_api.dart +++ b/lib/data/services/db/sqflite_api.dart @@ -168,7 +168,13 @@ class SqfliteApi { // 修改商务会议助手标题 static Future editMeetingTitle(id, Map meeting) async { final db = await DatabaseHelper().database; - await db.update('meeting', meeting, where: 'id = ?', whereArgs: [id]); + try { + await db.update('meeting', meeting, where: 'id = ?', whereArgs: [id]); + return true; + } catch (e) { + Logger.error('SqfliteApi.editMeetingTitle failed for id=$id: $e'); + return false; + } } // 删除商务会议助手 diff --git a/lib/data/services/meeting/meeting_task_service.dart b/lib/data/services/meeting/meeting_task_service.dart index fb748070b..65d5831c7 100644 --- a/lib/data/services/meeting/meeting_task_service.dart +++ b/lib/data/services/meeting/meeting_task_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import '../../../core/utils/logger.dart'; import '../db/sqflite_api.dart'; import '../network/api.dart'; import '../../models/user_Info.dart'; @@ -143,6 +144,11 @@ class MeetingTaskService extends GetxService { TranscribeService serviceType, ) async { tasktype.value = 3; + String oldSummary = ''; + try { + final data = await SqfliteApi.getMeetingDetails(id); + oldSummary = (data['details']?['summary'] ?? '').toString(); + } catch (_) {} await Api.refreshTaskEchomeet({ 'id': id, 'templateid': templateid, @@ -150,9 +156,12 @@ class MeetingTaskService extends GetxService { 'tolanguage': tolanguage, 'service_type': serviceType.value, }); + _taskList.removeWhere((i) => i['id'] == id); _taskList.add({ 'id': id, 'tasktype': 3, + 'oldSummary': oldSummary, + 'pollAttempts': 0, }); await SqfliteApi.editMeetingTitle( id, @@ -174,11 +183,18 @@ class MeetingTaskService extends GetxService { void _executeTask() async { if (_isTask) return; _isTask = true; - while (_taskList.isNotEmpty) { - await Future.delayed(const Duration(seconds: 5)); - await _queryTask(); + try { + while (_taskList.isNotEmpty) { + await Future.delayed(const Duration(seconds: 5)); + try { + await _queryTask(); + } catch (e, st) { + Logger.error('MeetingTask polling _queryTask failed: $e\n$st'); + } + } + } finally { + _isTask = false; } - _isTask = false; } Future removeTask(int id) async { @@ -204,6 +220,11 @@ class MeetingTaskService extends GetxService { 'ids': ids, }); List queryList = response['records'] ?? []; + Logger.d( + 'MeetingTask', + 'poll: ids=$ids, server returned ${queryList.length} records, ' + 'states=${queryList.map((e) => '${e['id']}:${e['state']}').toList()}', + ); for (var element in queryList) { int id = element['id']; int newTasktype = element['state']; @@ -211,6 +232,32 @@ class MeetingTaskService extends GetxService { (i) => i['id'] == id, orElse: () => {}, ); + + // 重新总结的兜底:服务端有时会立刻回 state>=5 但 summary 还是旧的, + // 等真正的新内容生成后才更新。这里用提交重总结时记录的 oldSummary + // 做比对,只要服务端返回的 summary 还跟旧的一样,就当作未完成继续轮询, + // 避免任务被过早移出 _taskList、polling 停掉之后再也拿不到新内容。 + const int kMaxStaleAttempts = 60; // ~5 分钟(按 5s 间隔) + if (taskData.isNotEmpty && newTasktype >= 5) { + final String oldSummary = + (taskData['oldSummary'] ?? '').toString(); + final String returnedSummary = + (element['summary'] ?? '').toString(); + int attempts = (taskData['pollAttempts'] ?? 0) as int; + if (oldSummary.isNotEmpty && + returnedSummary == oldSummary && + attempts < kMaxStaleAttempts) { + taskData['pollAttempts'] = attempts + 1; + Logger.d( + 'MeetingTask', + 're-summary still pending for id=$id ' + '(server reports state=$newTasktype but summary unchanged, ' + 'attempt=${attempts + 1})', + ); + continue; + } + } + if (taskData.isNotEmpty) { int oldTasktype = taskData['tasktype']; if (oldTasktype != newTasktype) { @@ -233,12 +280,84 @@ class MeetingTaskService extends GetxService { } if (oldTasktype < 5 && newTasktype >= 5) { // 总结完成 + // 重新总结场景下 translate 也会被服务端按新目标语言重写, + // 必须把它落进 meetingspeaker 表,否则转写 tab 仍然显示旧语言。 + final translateField = element['translate']; + bool translateRefreshed = false; + Logger.d( + 'MeetingTask', + 'summary complete for id=$id: ' + 'translate ${translateField == null ? 'MISSING' : 'len=${translateField.toString().length}'}, ' + 'available element keys=${element.keys.toList()}', + ); + if (translateField != null && + translateField.toString().isNotEmpty) { + try { + List decodeList = jsonDecode(translateField); + List> speakerList = decodeList + .map((e) => Map.from(e)) + .toList(); + await SqfliteApi.deleteMeetingSpeaker(id); + await SqfliteApi.insertMeetingSpeaker(speakerList); + translateRefreshed = true; + } catch (e) { + Logger.error( + 'MeetingTask: failed to refresh meetingspeaker on ' + 'summary complete for id=$id: $e', + ); + } + } await SqfliteApi.editMeeting(id, { 'tasktype': newTasktype, 'personnel': element['personnel'] ?? '', 'summary': element['summary'] ?? '', 'overview': element['overview'] ?? '', }); + + // 兜底:如果 polling 响应没带 translate(服务端可能在 + // /echomeet_getrecords 里不返回完整字段),主动去 + // /echomeet_getallrecords 拉一次,把新翻译同步到本地 DB。 + if (!translateRefreshed) { + Logger.d( + 'MeetingTask', + 'translate missing from polling response; ' + 'triggering full sync to fetch new speakers for id=$id', + ); + try { + var allResp = await Api.getOperationAllRecord(); + List allRecords = allResp['records'] ?? []; + final fullRecord = allRecords.firstWhere( + (r) => r['id'] == id, + orElse: () => null, + ); + if (fullRecord != null && + fullRecord['translate'] != null && + fullRecord['translate'].toString().isNotEmpty) { + List decodeList = + jsonDecode(fullRecord['translate']); + List> speakerList = decodeList + .map((e) => Map.from(e)) + .toList(); + await SqfliteApi.deleteMeetingSpeaker(id); + await SqfliteApi.insertMeetingSpeaker(speakerList); + await SqfliteApi.editMeeting(id, { + 'personnel': fullRecord['personnel'] ?? '', + 'summary': fullRecord['summary'] ?? '', + 'overview': fullRecord['overview'] ?? '', + }); + Logger.d( + 'MeetingTask', + 'full sync wrote ${speakerList.length} speaker rows ' + 'for id=$id', + ); + } + } catch (e) { + Logger.error( + 'MeetingTask: fallback full sync failed for id=$id: $e', + ); + } + } + if (id == meetingId) { personnel = element['personnel'] ?? ''; summary = element['summary'] ?? ''; diff --git a/lib/main.dart b/lib/main.dart index 98d00e3da..c540024f2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'core/theme/theme_service.dart'; import 'core/bindings/initial_binding.dart'; import 'core/utils/logger.dart'; import 'core/services/native_localization_sync.dart'; +import 'data/models/appconfig.dart'; import 'data/services/login_service.dart'; import 'modules/meeting/controllers/meeting_home_controller.dart'; import 'modules/splash/controllers/splash_controller.dart'; @@ -32,6 +33,9 @@ void main() async { // 初始化存储 await GetStorage.init(); + // 恢复 AI 入口可见性缓存(绑定/解绑时写入,与登录/网络无关) + AppConfig.loadHideAiAssistantFromCache(); + // 加载环境变量 await dotenv.load(fileName: ".env"); Logger.i("Main", "环境变量加载成功"); diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index afca3899d..7d3227e81 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -1,6 +1,6 @@ // import 'dart:ffi'; // Not supported on web; not needed here -import '/data/models/appconfig_model.dart'; +import '/data/models/appconfig.dart'; import '/core/utils/logger.dart'; import '/data/models/user_Info.dart'; @@ -78,6 +78,8 @@ class DevicesController extends GetxController { devices[device.devicetype]!.add(device); } devices.refresh(); + // 解绑后清缓存,AI 入口恢复显示 + await AppConfig.setHideAiAssistant(false); Logger.info( "解绑设备 ${device.devicemac} 当前使用设备:${_bleManager.currentDeviceAddress}"); if (device.devicemac == _bleManager.currentDeviceAddress) { diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index 930f797cb..7cd7f8fd9 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -97,18 +97,25 @@ class MeetingDetailsController extends GetxController _meetingTaskService.meetingId = id; _getDataDetails(); _loadJS(); - _tasktypeListener = ever(_meetingTaskService.tasktype, (value) { - meetingDetails.value.tasktype = value; - if (_everType <= value) { - if (value >= 3 && value < 10000) { - textList.value = _meetingTaskService.textList; - } - if (value >= 5 && value < 10000) { - meetingDetails.value.personnel = _meetingTaskService.personnel; - meetingDetails.value.summary = _meetingTaskService.summary; - meetingDetails.value.overview = _meetingTaskService.overview; - summaryNumberModifications.value++; + _tasktypeListener = ever(_meetingTaskService.tasktype, (value) async { + if (_everType <= value && value >= 5 && value < 10000) { + final data = await SqfliteApi.getMeetingDetails(id); + final fresh = MeetingDetailsModel.fromJson(data['details']); + fresh.tasktype = value; + final speakerList = await SqfliteApi.getMeetingSpeaker(id); + if (speakerList.isNotEmpty) { + textList.value = List.from(speakerList); } + meetingDetails.value = fresh; + summaryNumberModifications.value++; + return; + } + meetingDetails.value.tasktype = value; + if (_everType <= value && + value >= 3 && + value < 10000 && + _meetingTaskService.textList.isNotEmpty) { + textList.value = _meetingTaskService.textList; } meetingDetails.refresh(); }); diff --git a/lib/modules/meeting/views/tabs/overview_tab.dart b/lib/modules/meeting/views/tabs/overview_tab.dart index 87e43f459..30b736261 100644 --- a/lib/modules/meeting/views/tabs/overview_tab.dart +++ b/lib/modules/meeting/views/tabs/overview_tab.dart @@ -18,14 +18,30 @@ class OverviewTab extends StatefulWidget { class _OverviewTabState extends State { final _controller = Get.find(); + late final Worker _summaryListener; + List _dataList = []; @override void initState() { super.initState(); - if (_controller.meetingDetails.value.overview.isNotEmpty) { - _dataList = _controller.meetingDetails.value.overview.split('\n'); - } + _refreshDataList(); + _summaryListener = + ever(_controller.summaryNumberModifications, (value) { + if (!mounted) return; + setState(_refreshDataList); + }); + } + + @override + void dispose() { + _summaryListener.dispose(); + super.dispose(); + } + + void _refreshDataList() { + final overview = _controller.meetingDetails.value.overview; + _dataList = overview.isNotEmpty ? overview.split('\n') : []; } @override diff --git a/lib/modules/meeting/views/tabs/summary_tab.dart b/lib/modules/meeting/views/tabs/summary_tab.dart index 352afc71f..c14bf69de 100644 --- a/lib/modules/meeting/views/tabs/summary_tab.dart +++ b/lib/modules/meeting/views/tabs/summary_tab.dart @@ -24,6 +24,7 @@ class _SummaryTabState extends State { final _controller = Get.find(); late final Worker _personnelListener; + late final Worker _summaryListener; final String _addressPrefix = '${'location'.tr}:'; // 对应中文:地点 String _addressText = ''; @@ -33,10 +34,11 @@ class _SummaryTabState extends State { final TextEditingController _personnelController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - final GlobalKey _editorKey = GlobalKey(); + GlobalKey _editorKey = GlobalKey(); FleatherController? _fleatherController; bool _hasFocus = false; + bool _editorTouched = false; @override void initState() { @@ -48,11 +50,18 @@ class _SummaryTabState extends State { setState(() { _hasFocus = _focusNode.hasFocus; }); + if (_focusNode.hasFocus) _editorTouched = true; }); _personnelListener = ever(_controller.personnelNumberModifications, (value) { _initController(); }); + _summaryListener = + ever(_controller.summaryNumberModifications, (value) { + if (!mounted) return; + if (_focusNode.hasFocus) return; + _initController(); + }); } void _initController() { @@ -66,9 +75,17 @@ class _SummaryTabState extends State { : '[${'inputAttendees'.tr}]'); // 对应中文:输入与会人员 final markdown = const ParchmentMarkdownCodec() .decode(_controller.meetingDetails.value.summary); + final oldController = _fleatherController; setState(() { _fleatherController = FleatherController(document: markdown); + _editorKey = GlobalKey(); + _editorTouched = false; }); + if (oldController != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + oldController.dispose(); + }); + } } void _handleAddressChanged() { @@ -132,6 +149,7 @@ class _SummaryTabState extends State { } void _saveSummary() async { + if (!_editorTouched) return; final markdown = const ParchmentMarkdownCodec().encode(_fleatherController!.document); if (markdown != _controller.meetingDetails.value.summary) { @@ -151,6 +169,7 @@ class _SummaryTabState extends State { @override void dispose() { _personnelListener.dispose(); + _summaryListener.dispose(); _saveSummary(); _fleatherController?.dispose(); _focusNode.dispose(); @@ -338,6 +357,7 @@ class _SummaryTabState extends State { padding: EdgeInsets.only(top: 10.w), child: _controller.meetingDetails.value.tasktype < 10000 ? FleatherEditor( + key: ValueKey(_fleatherController), controller: _fleatherController!, focusNode: _focusNode, editorKey: _editorKey, diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 43735c595..6e40c7e2f 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -859,7 +859,8 @@ class PairingController extends GetxController with WidgetsBindingObserver { // 已绑定设备直接用 license 作为 deviceCode(即绑定时扫到的 SN) if (existingDevice.license.isNotEmpty) { deviceCode.value = existingDevice.license; - Logger.i(_tag, '[YIDONG] 已绑定设备,从license提取 deviceCode=${existingDevice.license}'); + Logger.i(_tag, + '[YIDONG] 已绑定设备,从license提取 deviceCode=${existingDevice.license}'); } return 10; } @@ -897,15 +898,18 @@ class PairingController extends GetxController with WidgetsBindingObserver { code = extractAndReverseBytes(hexData, 13, 22); _verifiedDevicePid = extractAndReverseBytesToInt(hexData, 4, 5); - final pidHexIos = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; - Logger.i(_tag, '[YIDONG] iOS BLE扫描 MAC匹配 device=${device.name} pidHex=$pidHexIos code=$code'); + final pidHexIos = + _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; + Logger.i(_tag, + '[YIDONG] iOS BLE扫描 MAC匹配 device=${device.name} pidHex=$pidHexIos code=$code'); if (code != "") { _bleManager.stopScan(); deviceCode.value = code; String? uuids = deviceInfo["id"]; _verifiedDeviceName = device.name; _verifiedDeviceAddress = uuids; - Logger.i(_tag, '[YIDONG] iOS deviceCode提取成功 code=$code uuid=$uuids'); + Logger.i(_tag, + '[YIDONG] iOS deviceCode提取成功 code=$code uuid=$uuids'); //检查是否已经绑定 final existingDevice = User.instance.devices.firstWhere( (d) => @@ -935,14 +939,17 @@ class PairingController extends GetxController with WidgetsBindingObserver { code = extractAndReverseBytes(hexData, 15, 24); _verifiedDevicePid = extractAndReverseBytesToInt(hexData, 6, 7); - final pidHexAndroid = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; - Logger.i(_tag, '[YIDONG] Android BLE扫描 MAC匹配 device=${device.name} mac=${device.address} pidHex=$pidHexAndroid code=$code'); + final pidHexAndroid = + _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; + Logger.i(_tag, + '[YIDONG] Android BLE扫描 MAC匹配 device=${device.name} mac=${device.address} pidHex=$pidHexAndroid code=$code'); if (code != "") { _bleManager.stopScan(); deviceCode.value = code; _verifiedDeviceName = device.name; _verifiedDeviceAddress = device.address; - Logger.i(_tag, '[YIDONG] Android deviceCode提取成功 code=$code mac=${device.address}'); + Logger.i(_tag, + '[YIDONG] Android deviceCode提取成功 code=$code mac=${device.address}'); //检查是否已经绑定 final existingDevice = User.instance.devices.firstWhere( (d) => @@ -1071,6 +1078,26 @@ class PairingController extends GetxController with WidgetsBindingObserver { // 根据API设计调整 final device = UserDevice.fromJson(response['device']); User.instance.addUserDevice(device); + // 新绑定设备:按该设备 DBProduct.hideaiassistant 写入本地缓存 + // 一律以 pid 为准(设备名用户可改、广播可能不带 LocalName,不能用作匹配键)。 + // 优先用本地 BLE 扫描出的 _verifiedDevicePid(也就是请求里送给服务端的 pid), + // 兜底再用服务端回包里的 device.productid。 + bool hide = false; + try { + final int pid = _verifiedDevicePid ?? device.productid; + DBProduct? product; + try { + product = AppConfig.config.products.firstWhere((p) => p.id == pid); + } catch (_) { + product = null; + } + hide = product?.hideaiassistant ?? false; + Logger.i(_tag, + '绑定设备 hideaiassistant 查询: pid=$pid product=${product?.devicename} hide=$hide'); + } catch (e) { + Logger.e(_tag, '查询产品 hideaiassistant 失败: $e'); + } + await AppConfig.setHideAiAssistant(hide); _transition(PairingState.verifying); _verifyDevice(_currentClassicDevice!); } @@ -1204,7 +1231,8 @@ class PairingController extends GetxController with WidgetsBindingObserver { int? pid = _verifiedDevicePid; String pidSource = 'BLE扫描'; if (pid == null) { - final mac = _verifiedDeviceAddress ?? _currentClassicDevice?.address ?? ''; + final mac = + _verifiedDeviceAddress ?? _currentClassicDevice?.address ?? ''; Logger.i(_tag, '[YIDONG] 本次未扫描到pid,从已绑定设备查找 mac=$mac'); if (mac.isNotEmpty) { final matched = User.instance.devices.firstWhere( @@ -1212,8 +1240,13 @@ class PairingController extends GetxController with WidgetsBindingObserver { d.devicemac.toUpperCase().replaceAll(':', '') == mac.toUpperCase().replaceAll(':', ''), orElse: () => UserDevice( - id: -1, uid: '', productid: -1, - devicetype: -1, devicename: '', devicemac: '', license: ''), + id: -1, + uid: '', + productid: -1, + devicetype: -1, + devicename: '', + devicemac: '', + license: ''), ); if (matched.id != -1) { pid = matched.productid; diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt index cc96c65c5..eb3f5b5df 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt @@ -117,12 +117,14 @@ class AstStereoRecorder { for (i in 0 until sampleCount) { val srcIdx = i * 2 val dstIdx = i * 4 - // 左声道 - stereo[dstIdx] = mono[srcIdx] - stereo[dstIdx + 1] = mono[srcIdx + 1] - // 右声道 - stereo[dstIdx + 2] = right[srcIdx] - stereo[dstIdx + 3] = right[srcIdx + 1] + // 左声道:本端麦克风原音,应用 LEFT_GAIN 拉响 + val lOutU = gainSoftClip(mono[srcIdx], mono[srcIdx + 1], LEFT_GAIN) + stereo[dstIdx] = (lOutU and 0xFF).toByte() + stereo[dstIdx + 1] = ((lOutU shr 8) and 0xFF).toByte() + // 右声道:B 路 TTS 合成音,应用 RIGHT_GAIN 压低 + val rOutU = gainSoftClip(right[srcIdx], right[srcIdx + 1], RIGHT_GAIN) + stereo[dstIdx + 2] = (rOutU and 0xFF).toByte() + stereo[dstIdx + 3] = ((rOutU shr 8) and 0xFF).toByte() } fileToWrite = file } @@ -130,7 +132,29 @@ class AstStereoRecorder { fileToWrite?.saveAudioDataToWav(stereo) } + /** + * 对一个 16-bit little-endian PCM 样本施加线性增益并 tanh 软削波。 + * 返回 [0, 0xFFFF] 区间的 Int(即 UInt16 的 bit pattern),交给调用方拆成两个字节。 + * gain==1.0 时短路返回原始 bit pattern,避免无谓浮点。 + */ + private fun gainSoftClip(lo: Byte, hi: Byte, gain: Float): Int { + val unsigned = (lo.toInt() and 0xFF) or ((hi.toInt() and 0xFF) shl 8) + if (gain == 1.0f) return unsigned + val s = unsigned.toShort().toInt() // 还原为带符号 Int16 + val x = (s.toFloat() / 32767.0f) * gain + val y = Math.tanh(x.toDouble()).toFloat() + var v = y * 32767.0f + if (v > 32767.0f) v = 32767.0f + if (v < -32768.0f) v = -32768.0f + return v.toInt() and 0xFFFF + } + companion object { private const val TAG = "AstStereoRecorder" + + // 通话翻译录音中麦克风原音偏小、对方 TTS 偏响,对两路分别做线性增益再 tanh 软削波。 + // 左(麦克风) +12dB ≈ 3.98x,右(TTS) -4dB ≈ 0.63x,与 iOS 端 AstStereoRecorder 保持一致。 + private const val LEFT_GAIN = 3.98f + private const val RIGHT_GAIN = 0.63f } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift index 533139c71..29f08f7bb 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift @@ -11,6 +11,11 @@ import os.log public final class AstStereoRecorder { private let log = OSLog(subsystem: "com.azure.speech", category: "AstStereoRecorder") + /// 通话翻译录音中麦克风原音偏小、对方 TTS 偏响,对两路分别做线性增益再 tanh 软削波。 + /// 左(麦克风) +12dB ≈ 3.98x,右(TTS) -4dB ≈ 0.63x,与 Android 端 AstStereoRecorder 保持一致。 + private static let leftGain: Float = 3.98 + private static let rightGain: Float = 0.63 + /// 受写入队列保护的状态 private let stateQueue = DispatchQueue(label: "ast.stereo.recorder.state") private var recordFile: RecordFile? @@ -123,10 +128,14 @@ public final class AstStereoRecorder { let rBase = rPtr.bindMemory(to: UInt8.self).baseAddress! let sampleCount = leftBytes / 2 for i in 0.. (UInt8, UInt8) { + if gain == 1.0 { return (lo, hi) } + let unsigned = UInt16(lo) | (UInt16(hi) << 8) + let s = Int16(bitPattern: unsigned) + let x = (Float(s) / 32767.0) * gain + let y = tanh(x) + var v = y * 32767.0 + if v > 32767.0 { v = 32767.0 } + if v < -32768.0 { v = -32768.0 } + let outU = UInt16(bitPattern: Int16(v)) + return (UInt8(outU & 0xFF), UInt8((outU >> 8) & 0xFF)) + } }