Browse Source

修复 通话录音和会议暨里重新总结问题

newdev_shunjiawei
liwei1dao 5 months ago
parent
commit
4726e49834
  1. 17
      lib/data/models/appconfig.dart
  2. 25
      lib/data/services/ble_manager.dart
  3. 8
      lib/data/services/db/sqflite_api.dart
  4. 127
      lib/data/services/meeting/meeting_task_service.dart
  5. 4
      lib/main.dart
  6. 4
      lib/modules/devices/controllers/devices_controller.dart
  7. 29
      lib/modules/meeting/controllers/meeting_details_controller.dart
  8. 22
      lib/modules/meeting/views/tabs/overview_tab.dart
  9. 22
      lib/modules/meeting/views/tabs/summary_tab.dart
  10. 53
      lib/modules/pairing/controllers/pairing_controller.dart
  11. 36
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AstStereoRecorder.kt
  12. 33
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift

17
lib/data/models/appconfig.dart

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../../data/models/appconfig_model.dart'; import '../../../data/models/appconfig_model.dart';
@ -8,7 +9,9 @@ class AppConfig {
// 是否隐藏"AI全能助手"模块(响应式)。 // 是否隐藏"AI全能助手"模块(响应式)。
// true => 首页/发现页切换为精简布局,新手引导跳过 AI 引导步骤 // true => 首页/发现页切换为精简布局,新手引导跳过 AI 引导步骤
// false => 保持原有布局 // false => 保持原有布局
// 数据来源:连接的 BLE 设备对应 DBProduct.hideaiassistant,由 BleManager 同步。 // 数据来源:绑定设备时按 DBProduct.hideaiassistant 写入本地缓存,
// 之后只在「绑定新设备 / 解绑设备」时重写。与 BLE 是否连接无关。
static const String hideAiStorageKey = 'hide_ai_assistant';
static final RxBool hideAiAssistant = false.obs; static final RxBool hideAiAssistant = false.obs;
static late UserGetAppConfigResp config; static late UserGetAppConfigResp config;
@ -61,4 +64,16 @@ class AppConfig {
.firstWhere((product) => product.devicename.contains(name)); .firstWhere((product) => product.devicename.contains(name));
return foundProduct; return foundProduct;
} }
// 从本地缓存恢复 hideAiAssistant。在 App 启动(GetStorage.init 之后)调用一次。
static void loadHideAiAssistantFromCache() {
final cached = GetStorage().read<bool>(hideAiStorageKey);
hideAiAssistant.value = cached ?? false;
}
// 设置 hideAiAssistant 并写入本地缓存。绑定/解绑时调用。
static Future<void> setHideAiAssistant(bool hide) async {
hideAiAssistant.value = hide;
await GetStorage().write(hideAiStorageKey, hide);
}
} }

25
lib/data/services/ble_manager.dart

@ -261,31 +261,6 @@ class BleManager extends GetxService {
// 设置BLE事件监听 // 设置BLE事件监听
_setupBleListener(); _setupBleListener();
// 同步 AI 助手隐藏开关:deviceInfo 拿到名字后按 DBProduct 配置写入;
// 断连时 deviceInfo 会被置 null,统一在断连分支复位。
ever<DeviceInfo?>(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');
}
} }
/// 释放资源 /// 释放资源

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

@ -168,7 +168,13 @@ class SqfliteApi {
// 修改商务会议助手标题 // 修改商务会议助手标题
static Future editMeetingTitle(id, Map<String, Object?> meeting) async { static Future editMeetingTitle(id, Map<String, Object?> meeting) async {
final db = await DatabaseHelper().database; 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;
}
} }
// 删除商务会议助手 // 删除商务会议助手

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

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart';
import '../db/sqflite_api.dart'; import '../db/sqflite_api.dart';
import '../network/api.dart'; import '../network/api.dart';
import '../../models/user_Info.dart'; import '../../models/user_Info.dart';
@ -143,6 +144,11 @@ class MeetingTaskService extends GetxService {
TranscribeService serviceType, TranscribeService serviceType,
) async { ) async {
tasktype.value = 3; tasktype.value = 3;
String oldSummary = '';
try {
final data = await SqfliteApi.getMeetingDetails(id);
oldSummary = (data['details']?['summary'] ?? '').toString();
} catch (_) {}
await Api.refreshTaskEchomeet({ await Api.refreshTaskEchomeet({
'id': id, 'id': id,
'templateid': templateid, 'templateid': templateid,
@ -150,9 +156,12 @@ class MeetingTaskService extends GetxService {
'tolanguage': tolanguage, 'tolanguage': tolanguage,
'service_type': serviceType.value, 'service_type': serviceType.value,
}); });
_taskList.removeWhere((i) => i['id'] == id);
_taskList.add({ _taskList.add({
'id': id, 'id': id,
'tasktype': 3, 'tasktype': 3,
'oldSummary': oldSummary,
'pollAttempts': 0,
}); });
await SqfliteApi.editMeetingTitle( await SqfliteApi.editMeetingTitle(
id, id,
@ -174,11 +183,18 @@ class MeetingTaskService extends GetxService {
void _executeTask() async { void _executeTask() async {
if (_isTask) return; if (_isTask) return;
_isTask = true; _isTask = true;
while (_taskList.isNotEmpty) { try {
await Future.delayed(const Duration(seconds: 5)); while (_taskList.isNotEmpty) {
await _queryTask(); 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<void> removeTask(int id) async { Future<void> removeTask(int id) async {
@ -204,6 +220,11 @@ class MeetingTaskService extends GetxService {
'ids': ids, 'ids': ids,
}); });
List queryList = response['records'] ?? []; 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) { for (var element in queryList) {
int id = element['id']; int id = element['id'];
int newTasktype = element['state']; int newTasktype = element['state'];
@ -211,6 +232,32 @@ class MeetingTaskService extends GetxService {
(i) => i['id'] == id, (i) => i['id'] == id,
orElse: () => {}, 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) { if (taskData.isNotEmpty) {
int oldTasktype = taskData['tasktype']; int oldTasktype = taskData['tasktype'];
if (oldTasktype != newTasktype) { if (oldTasktype != newTasktype) {
@ -233,12 +280,84 @@ class MeetingTaskService extends GetxService {
} }
if (oldTasktype < 5 && newTasktype >= 5) { 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<dynamic> decodeList = jsonDecode(translateField);
List<Map<String, Object?>> speakerList = decodeList
.map((e) => Map<String, Object?>.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, { await SqfliteApi.editMeeting(id, {
'tasktype': newTasktype, 'tasktype': newTasktype,
'personnel': element['personnel'] ?? '', 'personnel': element['personnel'] ?? '',
'summary': element['summary'] ?? '', 'summary': element['summary'] ?? '',
'overview': element['overview'] ?? '', '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<dynamic> decodeList =
jsonDecode(fullRecord['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, {
'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) { if (id == meetingId) {
personnel = element['personnel'] ?? ''; personnel = element['personnel'] ?? '';
summary = element['summary'] ?? ''; summary = element['summary'] ?? '';

4
lib/main.dart

@ -20,6 +20,7 @@ import 'core/theme/theme_service.dart';
import 'core/bindings/initial_binding.dart'; import 'core/bindings/initial_binding.dart';
import 'core/utils/logger.dart'; import 'core/utils/logger.dart';
import 'core/services/native_localization_sync.dart'; import 'core/services/native_localization_sync.dart';
import 'data/models/appconfig.dart';
import 'data/services/login_service.dart'; import 'data/services/login_service.dart';
import 'modules/meeting/controllers/meeting_home_controller.dart'; import 'modules/meeting/controllers/meeting_home_controller.dart';
import 'modules/splash/controllers/splash_controller.dart'; import 'modules/splash/controllers/splash_controller.dart';
@ -32,6 +33,9 @@ void main() async {
// 初始化存储 // 初始化存储
await GetStorage.init(); await GetStorage.init();
// 恢复 AI 入口可见性缓存(绑定/解绑时写入,与登录/网络无关)
AppConfig.loadHideAiAssistantFromCache();
// 加载环境变量 // 加载环境变量
await dotenv.load(fileName: ".env"); await dotenv.load(fileName: ".env");
Logger.i("Main", "环境变量加载成功"); Logger.i("Main", "环境变量加载成功");

4
lib/modules/devices/controllers/devices_controller.dart

@ -1,6 +1,6 @@
// import 'dart:ffi'; // Not supported on web; not needed here // 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 '/core/utils/logger.dart';
import '/data/models/user_Info.dart'; import '/data/models/user_Info.dart';
@ -78,6 +78,8 @@ class DevicesController extends GetxController {
devices[device.devicetype]!.add(device); devices[device.devicetype]!.add(device);
} }
devices.refresh(); devices.refresh();
// 解绑后清缓存,AI 入口恢复显示
await AppConfig.setHideAiAssistant(false);
Logger.info( Logger.info(
"解绑设备 ${device.devicemac} 当前使用设备:${_bleManager.currentDeviceAddress}"); "解绑设备 ${device.devicemac} 当前使用设备:${_bleManager.currentDeviceAddress}");
if (device.devicemac == _bleManager.currentDeviceAddress) { if (device.devicemac == _bleManager.currentDeviceAddress) {

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

@ -97,18 +97,25 @@ class MeetingDetailsController extends GetxController
_meetingTaskService.meetingId = id; _meetingTaskService.meetingId = id;
_getDataDetails(); _getDataDetails();
_loadJS(); _loadJS();
_tasktypeListener = ever(_meetingTaskService.tasktype, (value) { _tasktypeListener = ever(_meetingTaskService.tasktype, (value) async {
meetingDetails.value.tasktype = value; if (_everType <= value && value >= 5 && value < 10000) {
if (_everType <= value) { final data = await SqfliteApi.getMeetingDetails(id);
if (value >= 3 && value < 10000) { final fresh = MeetingDetailsModel.fromJson(data['details']);
textList.value = _meetingTaskService.textList; fresh.tasktype = value;
} final speakerList = await SqfliteApi.getMeetingSpeaker(id);
if (value >= 5 && value < 10000) { if (speakerList.isNotEmpty) {
meetingDetails.value.personnel = _meetingTaskService.personnel; textList.value = List.from(speakerList);
meetingDetails.value.summary = _meetingTaskService.summary;
meetingDetails.value.overview = _meetingTaskService.overview;
summaryNumberModifications.value++;
} }
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(); meetingDetails.refresh();
}); });

22
lib/modules/meeting/views/tabs/overview_tab.dart

@ -18,14 +18,30 @@ class OverviewTab extends StatefulWidget {
class _OverviewTabState extends State<OverviewTab> { class _OverviewTabState extends State<OverviewTab> {
final _controller = Get.find<MeetingDetailsController>(); final _controller = Get.find<MeetingDetailsController>();
late final Worker _summaryListener;
List _dataList = []; List _dataList = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (_controller.meetingDetails.value.overview.isNotEmpty) { _refreshDataList();
_dataList = _controller.meetingDetails.value.overview.split('\n'); _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 @override

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

@ -24,6 +24,7 @@ class _SummaryTabState extends State<SummaryTab> {
final _controller = Get.find<MeetingDetailsController>(); final _controller = Get.find<MeetingDetailsController>();
late final Worker _personnelListener; late final Worker _personnelListener;
late final Worker _summaryListener;
final String _addressPrefix = '${'location'.tr}:'; // 对应中文:地点 final String _addressPrefix = '${'location'.tr}:'; // 对应中文:地点
String _addressText = ''; String _addressText = '';
@ -33,10 +34,11 @@ class _SummaryTabState extends State<SummaryTab> {
final TextEditingController _personnelController = TextEditingController(); final TextEditingController _personnelController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final GlobalKey<EditorState> _editorKey = GlobalKey(); GlobalKey<EditorState> _editorKey = GlobalKey<EditorState>();
FleatherController? _fleatherController; FleatherController? _fleatherController;
bool _hasFocus = false; bool _hasFocus = false;
bool _editorTouched = false;
@override @override
void initState() { void initState() {
@ -48,11 +50,18 @@ class _SummaryTabState extends State<SummaryTab> {
setState(() { setState(() {
_hasFocus = _focusNode.hasFocus; _hasFocus = _focusNode.hasFocus;
}); });
if (_focusNode.hasFocus) _editorTouched = true;
}); });
_personnelListener = _personnelListener =
ever(_controller.personnelNumberModifications, (value) { ever(_controller.personnelNumberModifications, (value) {
_initController(); _initController();
}); });
_summaryListener =
ever(_controller.summaryNumberModifications, (value) {
if (!mounted) return;
if (_focusNode.hasFocus) return;
_initController();
});
} }
void _initController() { void _initController() {
@ -66,9 +75,17 @@ class _SummaryTabState extends State<SummaryTab> {
: '[${'inputAttendees'.tr}]'); // 对应中文:输入与会人员 : '[${'inputAttendees'.tr}]'); // 对应中文:输入与会人员
final markdown = const ParchmentMarkdownCodec() final markdown = const ParchmentMarkdownCodec()
.decode(_controller.meetingDetails.value.summary); .decode(_controller.meetingDetails.value.summary);
final oldController = _fleatherController;
setState(() { setState(() {
_fleatherController = FleatherController(document: markdown); _fleatherController = FleatherController(document: markdown);
_editorKey = GlobalKey<EditorState>();
_editorTouched = false;
}); });
if (oldController != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
oldController.dispose();
});
}
} }
void _handleAddressChanged() { void _handleAddressChanged() {
@ -132,6 +149,7 @@ class _SummaryTabState extends State<SummaryTab> {
} }
void _saveSummary() async { void _saveSummary() async {
if (!_editorTouched) return;
final markdown = final markdown =
const ParchmentMarkdownCodec().encode(_fleatherController!.document); const ParchmentMarkdownCodec().encode(_fleatherController!.document);
if (markdown != _controller.meetingDetails.value.summary) { if (markdown != _controller.meetingDetails.value.summary) {
@ -151,6 +169,7 @@ class _SummaryTabState extends State<SummaryTab> {
@override @override
void dispose() { void dispose() {
_personnelListener.dispose(); _personnelListener.dispose();
_summaryListener.dispose();
_saveSummary(); _saveSummary();
_fleatherController?.dispose(); _fleatherController?.dispose();
_focusNode.dispose(); _focusNode.dispose();
@ -338,6 +357,7 @@ class _SummaryTabState extends State<SummaryTab> {
padding: EdgeInsets.only(top: 10.w), padding: EdgeInsets.only(top: 10.w),
child: _controller.meetingDetails.value.tasktype < 10000 child: _controller.meetingDetails.value.tasktype < 10000
? FleatherEditor( ? FleatherEditor(
key: ValueKey(_fleatherController),
controller: _fleatherController!, controller: _fleatherController!,
focusNode: _focusNode, focusNode: _focusNode,
editorKey: _editorKey, editorKey: _editorKey,

53
lib/modules/pairing/controllers/pairing_controller.dart

@ -859,7 +859,8 @@ class PairingController extends GetxController with WidgetsBindingObserver {
// 已绑定设备直接用 license 作为 deviceCode(即绑定时扫到的 SN) // 已绑定设备直接用 license 作为 deviceCode(即绑定时扫到的 SN)
if (existingDevice.license.isNotEmpty) { if (existingDevice.license.isNotEmpty) {
deviceCode.value = existingDevice.license; deviceCode.value = existingDevice.license;
Logger.i(_tag, '[YIDONG] 已绑定设备,从license提取 deviceCode=${existingDevice.license}'); Logger.i(_tag,
'[YIDONG] 已绑定设备,从license提取 deviceCode=${existingDevice.license}');
} }
return 10; return 10;
} }
@ -897,15 +898,18 @@ class PairingController extends GetxController with WidgetsBindingObserver {
code = extractAndReverseBytes(hexData, 13, 22); code = extractAndReverseBytes(hexData, 13, 22);
_verifiedDevicePid = _verifiedDevicePid =
extractAndReverseBytesToInt(hexData, 4, 5); extractAndReverseBytesToInt(hexData, 4, 5);
final pidHexIos = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; final pidHexIos =
Logger.i(_tag, '[YIDONG] iOS BLE扫描 MAC匹配 device=${device.name} pidHex=$pidHexIos code=$code'); _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? '';
Logger.i(_tag,
'[YIDONG] iOS BLE扫描 MAC匹配 device=${device.name} pidHex=$pidHexIos code=$code');
if (code != "") { if (code != "") {
_bleManager.stopScan(); _bleManager.stopScan();
deviceCode.value = code; deviceCode.value = code;
String? uuids = deviceInfo["id"]; String? uuids = deviceInfo["id"];
_verifiedDeviceName = device.name; _verifiedDeviceName = device.name;
_verifiedDeviceAddress = uuids; _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( final existingDevice = User.instance.devices.firstWhere(
(d) => (d) =>
@ -935,14 +939,17 @@ class PairingController extends GetxController with WidgetsBindingObserver {
code = extractAndReverseBytes(hexData, 15, 24); code = extractAndReverseBytes(hexData, 15, 24);
_verifiedDevicePid = _verifiedDevicePid =
extractAndReverseBytesToInt(hexData, 6, 7); extractAndReverseBytesToInt(hexData, 6, 7);
final pidHexAndroid = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; final pidHexAndroid =
Logger.i(_tag, '[YIDONG] Android BLE扫描 MAC匹配 device=${device.name} mac=${device.address} pidHex=$pidHexAndroid code=$code'); _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? '';
Logger.i(_tag,
'[YIDONG] Android BLE扫描 MAC匹配 device=${device.name} mac=${device.address} pidHex=$pidHexAndroid code=$code');
if (code != "") { if (code != "") {
_bleManager.stopScan(); _bleManager.stopScan();
deviceCode.value = code; deviceCode.value = code;
_verifiedDeviceName = device.name; _verifiedDeviceName = device.name;
_verifiedDeviceAddress = device.address; _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( final existingDevice = User.instance.devices.firstWhere(
(d) => (d) =>
@ -1071,6 +1078,26 @@ class PairingController extends GetxController with WidgetsBindingObserver {
// 根据API设计调整 // 根据API设计调整
final device = UserDevice.fromJson(response['device']); final device = UserDevice.fromJson(response['device']);
User.instance.addUserDevice(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); _transition(PairingState.verifying);
_verifyDevice(_currentClassicDevice!); _verifyDevice(_currentClassicDevice!);
} }
@ -1204,7 +1231,8 @@ class PairingController extends GetxController with WidgetsBindingObserver {
int? pid = _verifiedDevicePid; int? pid = _verifiedDevicePid;
String pidSource = 'BLE扫描'; String pidSource = 'BLE扫描';
if (pid == null) { if (pid == null) {
final mac = _verifiedDeviceAddress ?? _currentClassicDevice?.address ?? ''; final mac =
_verifiedDeviceAddress ?? _currentClassicDevice?.address ?? '';
Logger.i(_tag, '[YIDONG] 本次未扫描到pid,从已绑定设备查找 mac=$mac'); Logger.i(_tag, '[YIDONG] 本次未扫描到pid,从已绑定设备查找 mac=$mac');
if (mac.isNotEmpty) { if (mac.isNotEmpty) {
final matched = User.instance.devices.firstWhere( final matched = User.instance.devices.firstWhere(
@ -1212,8 +1240,13 @@ class PairingController extends GetxController with WidgetsBindingObserver {
d.devicemac.toUpperCase().replaceAll(':', '') == d.devicemac.toUpperCase().replaceAll(':', '') ==
mac.toUpperCase().replaceAll(':', ''), mac.toUpperCase().replaceAll(':', ''),
orElse: () => UserDevice( orElse: () => UserDevice(
id: -1, uid: '', productid: -1, id: -1,
devicetype: -1, devicename: '', devicemac: '', license: ''), uid: '',
productid: -1,
devicetype: -1,
devicename: '',
devicemac: '',
license: ''),
); );
if (matched.id != -1) { if (matched.id != -1) {
pid = matched.productid; pid = matched.productid;

36
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) { for (i in 0 until sampleCount) {
val srcIdx = i * 2 val srcIdx = i * 2
val dstIdx = i * 4 val dstIdx = i * 4
// 左声道 // 左声道:本端麦克风原音,应用 LEFT_GAIN 拉响
stereo[dstIdx] = mono[srcIdx] val lOutU = gainSoftClip(mono[srcIdx], mono[srcIdx + 1], LEFT_GAIN)
stereo[dstIdx + 1] = mono[srcIdx + 1] stereo[dstIdx] = (lOutU and 0xFF).toByte()
// 右声道 stereo[dstIdx + 1] = ((lOutU shr 8) and 0xFF).toByte()
stereo[dstIdx + 2] = right[srcIdx] // 右声道:B 路 TTS 合成音,应用 RIGHT_GAIN 压低
stereo[dstIdx + 3] = right[srcIdx + 1] 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 fileToWrite = file
} }
@ -130,7 +132,29 @@ class AstStereoRecorder {
fileToWrite?.saveAudioDataToWav(stereo) 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 { companion object {
private const val TAG = "AstStereoRecorder" 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
} }
} }

33
local_plugins/azure_speech/ios/azure_speech/Sources/tools/AstStereoRecorder.swift

@ -11,6 +11,11 @@ import os.log
public final class AstStereoRecorder { public final class AstStereoRecorder {
private let log = OSLog(subsystem: "com.azure.speech", category: "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 let stateQueue = DispatchQueue(label: "ast.stereo.recorder.state")
private var recordFile: RecordFile? private var recordFile: RecordFile?
@ -123,10 +128,14 @@ public final class AstStereoRecorder {
let rBase = rPtr.bindMemory(to: UInt8.self).baseAddress! let rBase = rPtr.bindMemory(to: UInt8.self).baseAddress!
let sampleCount = leftBytes / 2 let sampleCount = leftBytes / 2
for i in 0..<sampleCount { for i in 0..<sampleCount {
stereo.append(lBase[i * 2]) let (lLo, lHi) = AstStereoRecorder.gainSoftClip(
stereo.append(lBase[i * 2 + 1]) lBase[i * 2], lBase[i * 2 + 1], gain: AstStereoRecorder.leftGain)
stereo.append(rBase[i * 2]) stereo.append(lLo)
stereo.append(rBase[i * 2 + 1]) stereo.append(lHi)
let (rLo, rHi) = AstStereoRecorder.gainSoftClip(
rBase[i * 2], rBase[i * 2 + 1], gain: AstStereoRecorder.rightGain)
stereo.append(rLo)
stereo.append(rHi)
} }
} }
} }
@ -137,4 +146,20 @@ public final class AstStereoRecorder {
file.saveAudioDataToWav(stereo) file.saveAudioDataToWav(stereo)
} }
} }
/// 对一个 16-bit little-endian PCM 样本施加线性增益并 tanh 软削波。
/// gain==1.0 时短路返回原字节,避免无谓浮点。
@inline(__always)
private static func gainSoftClip(_ lo: UInt8, _ hi: UInt8, gain: Float) -> (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))
}
} }

Loading…
Cancel
Save