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. 6
      lib/data/services/db/sqflite_api.dart
  4. 119
      lib/data/services/meeting/meeting_task_service.dart
  5. 4
      lib/main.dart
  6. 4
      lib/modules/devices/controllers/devices_controller.dart
  7. 25
      lib/modules/meeting/controllers/meeting_details_controller.dart
  8. 20
      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 '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<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事件监听
_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');
}
}
/// 释放资源

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

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

119
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,12 +183,19 @@ class MeetingTaskService extends GetxService {
void _executeTask() async {
if (_isTask) return;
_isTask = true;
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;
}
}
Future<void> removeTask(int id) async {
_taskList.removeWhere((element) => element['id'] == id);
@ -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<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, {
'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<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) {
personnel = element['personnel'] ?? '';
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/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", "环境变量加载成功");

4
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) {

25
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;
_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);
}
if (value >= 5 && value < 10000) {
meetingDetails.value.personnel = _meetingTaskService.personnel;
meetingDetails.value.summary = _meetingTaskService.summary;
meetingDetails.value.overview = _meetingTaskService.overview;
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();
});

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

@ -18,14 +18,30 @@ class OverviewTab extends StatefulWidget {
class _OverviewTabState extends State<OverviewTab> {
final _controller = Get.find<MeetingDetailsController>();
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

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

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

53
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;

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) {
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
}
}

33
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..<sampleCount {
stereo.append(lBase[i * 2])
stereo.append(lBase[i * 2 + 1])
stereo.append(rBase[i * 2])
stereo.append(rBase[i * 2 + 1])
let (lLo, lHi) = AstStereoRecorder.gainSoftClip(
lBase[i * 2], lBase[i * 2 + 1], gain: AstStereoRecorder.leftGain)
stereo.append(lLo)
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)
}
}
/// 对一个 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