Browse Source
## 通话/同声/面对面翻译 → 语音纪要 三种翻译模式默认边翻边录,结束时把音频**连同已有转写**一起归档 (rtype=VOICETRANSLAT),服务端跳过识别、直接用智能模板(General-Autopilot)总结, 在会议纪要里就能看到。转写段的构造见 translation_export.dart(纯函数,带金测试): - 时间是**相对录音起点的毫秒**并扣掉暂停区间——面对面是逐句按住说话、松开就暂停的, 不扣的话越往后偏得越多;写成绝对时间戳会让点一句跳到音频末尾之外; - 说话人用 Speaker_0/Speaker_1,与服务端转写同一口径(转写页直接把这个字段当文案显示, 裸 0/1 会和别的纪要长得不一样,喂给 LLM 抽待办时也会混出 Speaker2 这种负责人); - AST socket 断开后 _terminateCallOnError 会取消事件订阅,重开时收不到任何转写 (真机上表现为「有日志但转写 0 段」),改成重新订阅。 ## 语音纪要列表:服务端是真源、本地 sqlite 是缓存 列表接口改回骨架字段后(服务端同批),客户端改成本地优先渲染 + 后台对齐: - Synchrodata 重写:拉骨架 → upsert → 删掉「本地有服务端没有」的记录(连本地音频、 上传队列、轮询任务一起清)。**业务错误时 data 是 null 不是空列表**,判空错了 会把本地整个清空; - 详情进页面时才拉整条;离线操作队列那套上行是死代码(服务端接口整个注释掉了),删除。 ## 登录按包收敛 苹果包只留苹果+游客、安卓包只留微信+游客、谷歌包只留邮箱+游客。 判定抽成 resolveLoginMethods 纯函数(login_methods.dart + 8 条测试), 邮箱包还要锁死国家区号选择。 ## EMAI 与其它 - 手机侧触发的对话走 A2DP 而不是语音处理通道:iOS 上 A2DP 输出与 voiceChat 模式 互斥,开了后者蓝牙耳机会当场切到手机扬声器。按当前实际输出路由决定要不要开 voiceProcessing/回声消除; - EMAI「开始对话」闪退:探测输入格式时 new 了一个一次性 AVAudioEngine 去读 inputNode, 配置变更时崩在 AVAudioIONodeImpl(EXC_BAD_ACCESS)。改成直接问 AVAudioSession; - 语音纪要文件名可改:每行右侧加铅笔图标,复用已有的重命名弹窗(原来只有长按菜单, 实际入口已不可达); - 拾忆上报 IANA 时区名:DateTime.now().timeZoneName 给的是「CST」这种缩写, 服务端 LoadLocation 解析不了、静默退回容器时区,海外用户的提醒时刻会全按北京时间算。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
84 changed files with 1912 additions and 468 deletions
@ -1,193 +1,133 @@ |
|||||
import 'dart:convert'; |
import 'dart:io'; |
||||
|
|
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import 'package:get_storage/get_storage.dart'; |
|
||||
|
|
||||
import '../../data/services/db/database_helper.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/meeting/meeting_task_service.dart'; |
||||
|
import '../../data/services/meeting/meeting_upload_service.dart'; |
||||
import '../../data/services/network/api.dart'; |
import '../../data/services/network/api.dart'; |
||||
|
import 'logger.dart'; |
||||
|
|
||||
|
/// 语音纪要列表与服务端对齐。 |
||||
|
/// |
||||
|
/// 模型:**服务端是唯一真源,本地 sqlite 只是缓存。** |
||||
|
/// - `echomeet_getallrecords` 只回骨架(title/rtype/state/seconds/audiourl/creationtime), |
||||
|
/// 每条 ~250 字节,上千条也是一次拉完,所以没有分页、没有增量、没有软删除; |
||||
|
/// - 转写(`meetingspeaker`) 与总结(`summary/overview/personnel`) **不在这里写**, |
||||
|
/// 详情页进页面时 `echomeet_getrecord` 拉整条再落库(见 MeetingDetailsController); |
||||
|
/// - 本地有、服务端没有的记录 = 在别的设备上删了,这里跟着删。 |
||||
|
/// 本地行必然带服务端 id(归档是先 `addrecord` 拿到 id 才 `insertMeeting`),不会误删归档中的。 |
||||
|
/// |
||||
|
/// 原先这个类还带一套「离线操作队列 → echomeet_putrecords」的上行逻辑,服务端那个接口整个 |
||||
|
/// 注释掉了、客户端上传段也注释掉了,是死代码,2026-09-18 一并删除。 |
||||
class Synchrodata { |
class Synchrodata { |
||||
static final GetStorage _storage = GetStorage(); |
static const String _tag = 'Synchrodata'; |
||||
|
static bool _running = false; |
||||
|
|
||||
static List<Map<String, dynamic>> _records = |
/// 拉一次骨架并对齐本地库。并发调用直接返回;失败只记日志(列表照用本地缓存)。 |
||||
(_storage.read("operation_records") as List?) |
static Future<void> sync() async { |
||||
?.map((e) => Map<String, dynamic>.from(e as Map)) |
if (_running) return; |
||||
.toList() ?? |
_running = true; |
||||
[]; |
try { |
||||
static List _delids = _storage.read("operation_delids") ?? []; |
await _sync(); |
||||
static bool _isSynchro = false; |
} catch (e, st) { |
||||
|
Logger.w(_tag, '列表同步失败(沿用本地缓存): $e\n$st'); |
||||
// 保存操作记录 |
} finally { |
||||
static Future saveOperationRecord( |
_running = false; |
||||
String method, |
|
||||
dynamic params, |
|
||||
) async { |
|
||||
String userId = ''; |
|
||||
Map? userInfo = _storage.read("user_info"); |
|
||||
if (userInfo != null) { |
|
||||
userId = userInfo['user']['uid']; |
|
||||
} |
|
||||
String id = '${userId}_${params['id']}'; |
|
||||
if (params['type'] != null) { |
|
||||
params['rtype'] = params['type']; |
|
||||
} |
|
||||
|
|
||||
switch (method) { |
|
||||
case 'insert': |
|
||||
_records.add({ |
|
||||
...Map<String, dynamic>.from(params), |
|
||||
'id': id, |
|
||||
'number': params['id'], |
|
||||
'uid': userId, |
|
||||
}); |
|
||||
_storage.write("operation_records", _records); |
|
||||
break; |
|
||||
case 'update': |
|
||||
Map<String, dynamic>? foundElement = _records.firstWhere( |
|
||||
(element) => element['id'] == id, |
|
||||
orElse: () => {}, |
|
||||
); |
|
||||
if (foundElement.isNotEmpty) { |
|
||||
foundElement.addAll({ |
|
||||
...Map<String, dynamic>.from(params), |
|
||||
'id': id, |
|
||||
'number': params['id'], |
|
||||
}); |
|
||||
} else { |
|
||||
_records.add({ |
|
||||
...Map<String, dynamic>.from(params), |
|
||||
'id': id, |
|
||||
'number': params['id'], |
|
||||
'uid': userId, |
|
||||
}); |
|
||||
} |
|
||||
_storage.write("operation_records", _records); |
|
||||
break; |
|
||||
case 'delete': |
|
||||
_delids.add(id); |
|
||||
_storage.write("operation_delids", _delids); |
|
||||
break; |
|
||||
default: |
|
||||
break; |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 上传操作记录 |
static Future<void> _sync() async { |
||||
static Future uploadOperationRecords({bool isSynchro = false}) async { |
final result = await Api.getOperationAllRecord(); |
||||
if (_isSynchro) return; |
// ⚠️ 业务错误时 AuthInterceptor 把 data 置 null 后 resolve(不抛),必须判空—— |
||||
_isSynchro = true; |
// 把 null 当「服务端没有记录」会把本地整个清空。 |
||||
// if (_records.isNotEmpty || _delids.isNotEmpty) { |
if (result == null) { |
||||
// // 获取需要修改的全部数据 |
Logger.w(_tag, '列表接口返回业务错误,本轮不对齐'); |
||||
// List putRecords = []; |
return; |
||||
// 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(); |
|
||||
} |
} |
||||
_isSynchro = false; |
final List records = result['records'] ?? []; |
||||
} |
final serverIds = <int>{}; |
||||
|
final taskList = <Map<String, Object?>>[]; |
||||
|
|
||||
// 同步数据 |
|
||||
static Future _synchrodata() async { |
|
||||
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; |
final db = await DatabaseHelper().database; |
||||
await db.transaction((txn) async { |
await db.transaction((txn) async { |
||||
for (var element in records) { |
for (final element in records) { |
||||
if (element['state'] > 0 && element['state'] < 5) { |
final int id = element['id']; |
||||
taskList.add({ |
final int state = element['state'] ?? 0; |
||||
'id': element['id'], |
serverIds.add(id); |
||||
'tasktype': element['state'], |
if (state > 0 && state < 5) { |
||||
}); |
taskList.add({'id': id, 'tasktype': state}); |
||||
} |
} |
||||
final List existList = await txn.query( |
final meeting = <String, Object?>{ |
||||
'meeting', |
'id': id, |
||||
where: 'id = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
Map<String, Object?> meetingData = { |
|
||||
'id': element['id'], |
|
||||
'title': element['title'] ?? '', |
'title': element['title'] ?? '', |
||||
'type': element['rtype'] ?? '', |
'type': element['rtype'] ?? '', |
||||
'seconds': element['seconds'] ?? 0, |
'seconds': element['seconds'] ?? 0, |
||||
'audiourl': element['audiourl'] ?? '', |
'audiourl': element['audiourl'] ?? '', |
||||
'tasktype': element['state'] ?? 0, |
'tasktype': state, |
||||
|
// 服务端秒,本地毫秒 |
||||
'creationtime': (element['creationtime'] ?? 0) * 1000, |
'creationtime': (element['creationtime'] ?? 0) * 1000, |
||||
}; |
}; |
||||
Map<String, Object?> meetingDetailsData = { |
final exists = await txn.query('meeting', |
||||
'meetingid': element['id'], |
columns: ['id'], where: 'id = ?', whereArgs: [id], limit: 1); |
||||
'address': element['address'] ?? '', |
if (exists.isNotEmpty) { |
||||
'personnel': element['personnel'] ?? '', |
// 不碰 filepath(本机录音的路径只有本机知道) |
||||
'taskid': element['taskid'] ?? '', |
await txn.update('meeting', meeting, where: 'id = ?', whereArgs: [id]); |
||||
'tasktype': element['state'] ?? 0, |
// 状态在两张表各存一份,以 meeting 表为准,details 跟着走(详情页读的是这份) |
||||
'overview': element['overview'] ?? '', |
await txn.update('meetingdetails', {'tasktype': state}, |
||||
'summary': element['summary'] ?? '', |
where: 'meetingid = ?', whereArgs: [id]); |
||||
'mindmap': '', |
|
||||
}; |
|
||||
if (existList.isNotEmpty) { |
|
||||
await txn.update( |
|
||||
'meeting', |
|
||||
meetingData, |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
await txn.update( |
|
||||
'meetingdetails', |
|
||||
meetingDetailsData, |
|
||||
where: 'meetingid = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
} else { |
} else { |
||||
await txn.insert('meeting', { |
await txn.insert('meeting', {...meeting, 'filepath': ''}); |
||||
...meetingData, |
await txn.insert('meetingdetails', { |
||||
'filepath': '', |
'meetingid': id, |
||||
|
'address': element['address'] ?? '', |
||||
|
'personnel': element['personnel'] ?? '', |
||||
|
'taskid': element['taskid'] ?? '', |
||||
|
'tasktype': state, |
||||
|
'overview': '', |
||||
|
'summary': '', |
||||
|
'mindmap': '', |
||||
}); |
}); |
||||
await txn.insert('meetingdetails', meetingDetailsData); |
|
||||
} |
|
||||
if (element['translate'].isNotEmpty) { |
|
||||
List speakerList = jsonDecode(element['translate']); |
|
||||
await txn.delete( |
|
||||
'meetingspeaker', |
|
||||
where: 'meetingid = ?', |
|
||||
whereArgs: [element['id']], |
|
||||
); |
|
||||
final batch = txn.batch(); |
|
||||
for (var item in speakerList) { |
|
||||
batch.insert('meetingspeaker', item); |
|
||||
} |
|
||||
await batch.commit(); |
|
||||
} |
} |
||||
} |
} |
||||
}); |
}); |
||||
meetingTaskService.startTask(taskList); |
|
||||
|
await _removeLocalOrphans(serverIds); |
||||
|
|
||||
|
if (taskList.isNotEmpty && Get.isRegistered<MeetingTaskService>()) { |
||||
|
Get.find<MeetingTaskService>().startTask(taskList); |
||||
|
} |
||||
|
Logger.i(_tag, '列表已对齐:服务端 ${records.length} 条,进行中 ${taskList.length} 条'); |
||||
|
} |
||||
|
|
||||
|
/// 删掉「本地有、服务端没有」的记录:连本地音频、上传队列、轮询任务一起清。 |
||||
|
static Future<void> _removeLocalOrphans(Set<int> serverIds) async { |
||||
|
final db = await DatabaseHelper().database; |
||||
|
final local = await db.query('meeting', columns: ['id', 'filepath']); |
||||
|
final orphans = local.where((r) => !serverIds.contains(r['id'] as int)); |
||||
|
for (final row in orphans) { |
||||
|
final int id = row['id'] as int; |
||||
|
final String path = (row['filepath'] as String?) ?? ''; |
||||
|
await db.transaction((txn) async { |
||||
|
await txn.delete('meeting', where: 'id = ?', whereArgs: [id]); |
||||
|
await txn.delete('meetingdetails', where: 'meetingid = ?', whereArgs: [id]); |
||||
|
await txn.delete('meetingspeaker', where: 'meetingid = ?', whereArgs: [id]); |
||||
|
}); |
||||
|
if (path.isNotEmpty) { |
||||
|
try { |
||||
|
final f = File(path); |
||||
|
if (await f.exists()) await f.delete(); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '删除孤儿录音文件失败 $path: $e'); |
||||
|
} |
||||
|
} |
||||
|
if (Get.isRegistered<MeetingUploadService>()) { |
||||
|
Get.find<MeetingUploadService>().removeUpload(id, isDeleteTask: true); |
||||
|
} |
||||
|
if (Get.isRegistered<MeetingTaskService>()) { |
||||
|
Get.find<MeetingTaskService>().removeLocalTask(id); |
||||
|
} |
||||
|
Logger.i(_tag, '服务端已不存在,删除本地记录 id=$id'); |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,48 @@ |
|||||
|
/// 登录页上可能出现的入口。 |
||||
|
enum LoginMethod { |
||||
|
/// 页面上方那组「手机号/邮箱 + 验证码 + 继续」。 |
||||
|
contact, |
||||
|
wechat, |
||||
|
apple, |
||||
|
guest, |
||||
|
} |
||||
|
|
||||
|
/// 这个**包**提供哪些登录方式(2026-09-18 产品口径)。 |
||||
|
/// |
||||
|
/// | 包 | 留下的入口 | |
||||
|
/// |---|---| |
||||
|
/// | 苹果包(iOS) | 苹果登录 + 游客 | |
||||
|
/// | 安卓国内包 | 微信登录 + 游客 | |
||||
|
/// | 谷歌包 | 邮箱登录 + 游客 | |
||||
|
/// |
||||
|
/// ⚠️ **判据是 `.env` 的 `Channel`,不是 `VersionUpdateService.cachedChannel`**。 |
||||
|
/// 后者在安卓上由设备品牌**异步**推导,推导完成之前 `_channel` 还是 null, |
||||
|
/// 而它的兜底恰好是 `GLOBAL_GOOGLE` —— 拿它做判据的话,国内包在进入登录页的 |
||||
|
/// 头几百毫秒会显示成谷歌包(露出邮箱登录、藏掉微信),品牌识别完再跳回来。 |
||||
|
/// `.env` 的 Channel 是打包时由 `build_channels.sh` 写死的(google → `GLOBAL_GOOGLE`), |
||||
|
/// 同步可读、不会变,正是「这是哪个包」本身。 |
||||
|
/// |
||||
|
/// ⚠️ iOS 必须**先判平台**:iOS 不走 `build_channels.sh`,`.env` 里留的还是 |
||||
|
/// 国内渠道值(`CHINA_XIAOMI`),只看 Channel 会把苹果包判成国内包。 |
||||
|
/// |
||||
|
/// [guestVisible] 由服务端下发(「游客显隐控制」按应用+版本配),与包无关, |
||||
|
/// 所以单独传进来而不是在这里推。 |
||||
|
Set<LoginMethod> resolveLoginMethods({ |
||||
|
required bool isIOS, |
||||
|
required String envChannel, |
||||
|
required bool guestVisible, |
||||
|
}) { |
||||
|
final methods = <LoginMethod>{}; |
||||
|
if (isIOS) { |
||||
|
methods.add(LoginMethod.apple); |
||||
|
} else if (envChannel.trim().toUpperCase() == 'GLOBAL_GOOGLE') { |
||||
|
// 谷歌包:邮箱登录(contact 这一档在非 iOS 只可能是谷歌包,故恒为邮箱, |
||||
|
// 见 LoginController.isEmailOnlyLogin) |
||||
|
methods.add(LoginMethod.contact); |
||||
|
} else { |
||||
|
// 其余安卓包(国内五家共用一个 APK,Channel 为 CHINA_*;认不出也按国内算) |
||||
|
methods.add(LoginMethod.wechat); |
||||
|
} |
||||
|
if (guestVisible) methods.add(LoginMethod.guest); |
||||
|
return methods; |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
/// 一次识别结果该按哪个方向翻译。 |
||||
|
/// |
||||
|
/// 抽成纯函数是为了能脱离设备/网络单测:方向判错不会报任何错, |
||||
|
/// 只表现为「识别出字了、译文不出来或者是原文」,靠真机盲测极难发现。 |
||||
|
class TranslationDirection { |
||||
|
const TranslationDirection(this.source, this.target); |
||||
|
|
||||
|
final String source; |
||||
|
final String target; |
||||
|
|
||||
|
@override |
||||
|
bool operator ==(Object other) => |
||||
|
other is TranslationDirection && |
||||
|
other.source == source && |
||||
|
other.target == target; |
||||
|
|
||||
|
@override |
||||
|
int get hashCode => Object.hash(source, target); |
||||
|
|
||||
|
@override |
||||
|
String toString() => '$source -> $target'; |
||||
|
} |
||||
|
|
||||
|
/// 单向翻译的模式:同声翻译、多媒体(音视频)翻译。 |
||||
|
/// |
||||
|
/// 这两个模式的语义是「把听到的内容翻成目标语言播出来」,方向固定。 |
||||
|
/// 面对面要判断谁在说(双向),通话翻译两条腿各自定死方向,都不在此列。 |
||||
|
bool isOneWayTranslationMode(String mode) => |
||||
|
mode == 'simultaneous' || mode == 'audioVideo'; |
||||
|
|
||||
|
/// 计算这一句的翻译方向。 |
||||
|
/// |
||||
|
/// [speaker] 仅面对面模式有意义:1 = 源语言那一方,2 = 目标语言那一方,0 = 未知。 |
||||
|
/// [detectedLanguageCode] 是 ASR 上报的语种,**只在面对面的未知说话人分支才采信**。 |
||||
|
TranslationDirection resolveTranslationDirection({ |
||||
|
required String mode, |
||||
|
required String sourceCode, |
||||
|
required String targetCode, |
||||
|
String detectedLanguageCode = '', |
||||
|
int speaker = 0, |
||||
|
}) { |
||||
|
// 单向:永远 源 → 目标,不看 ASR 报的语种。 |
||||
|
// |
||||
|
// ⚠️ 这两个模式给 ASR 的本来就只有一种语言,固定语种下原生上报的 |
||||
|
// detectedLanguage 形式并不保证与 sourceCode 逐字相等(空串 / 大小写 / |
||||
|
// 带不带地区都出现过)。老实现拿它去比,一旦不相等就判成反向, |
||||
|
// 把源语言的句子当成目标语言送去翻译 —— 方向整个颠倒。 |
||||
|
if (isOneWayTranslationMode(mode)) { |
||||
|
return TranslationDirection(sourceCode, targetCode); |
||||
|
} |
||||
|
if (mode == 'faceToFace') { |
||||
|
if (speaker == 1) return TranslationDirection(sourceCode, targetCode); |
||||
|
if (speaker == 2) return TranslationDirection(targetCode, sourceCode); |
||||
|
} |
||||
|
// 面对面且说不清是谁在说(或通话模式):按 ASR 检测结果定方向。 |
||||
|
final fromSource = detectedLanguageCode == sourceCode; |
||||
|
return fromSource |
||||
|
? TranslationDirection(sourceCode, targetCode) |
||||
|
: TranslationDirection(targetCode, sourceCode); |
||||
|
} |
||||
@ -0,0 +1,98 @@ |
|||||
|
import 'translation_models.dart'; |
||||
|
|
||||
|
/// 把一段翻译会话的条目转成语音纪要的转写段。 |
||||
|
/// |
||||
|
/// 输出形状与服务端 `ContextStruct`(`echomeet_addrecord` 的 `original`) |
||||
|
/// 和本地 `meetingspeaker` 表一致:`content / starttime / endtime / speaker`, |
||||
|
/// 时间是**相对录音起点的毫秒**——纪要详情页拿它做 seekTo,写成绝对时间戳 |
||||
|
/// 会让点一句跳到音频末尾之外。 |
||||
|
/// |
||||
|
/// 纯函数,不碰 controller,见 `test/translation_export_test.dart`。 |
||||
|
List<Map<String, Object?>> buildMeetingTranscript({ |
||||
|
required List<TranslationItem> items, |
||||
|
required DateTime recordingStartedAt, |
||||
|
required String mode, |
||||
|
required String ownLanguageCode, |
||||
|
List<(DateTime, DateTime)> pauses = const [], |
||||
|
}) { |
||||
|
// 只要**这段录音**里说过的整句:中间结果是同一句话的半成品, |
||||
|
// 录音开始前的是上一轮留在列表里的历史。 |
||||
|
final finals = items |
||||
|
.where((e) => |
||||
|
!e.isIntermediate && |
||||
|
e.sourceText.trim().isNotEmpty && |
||||
|
!e.timestamp.isBefore(recordingStartedAt)) |
||||
|
.toList() |
||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp)); |
||||
|
if (finals.isEmpty) return const []; |
||||
|
|
||||
|
final twoParty = mode == 'call' || mode == 'faceToFace'; |
||||
|
final out = <Map<String, Object?>>[]; |
||||
|
for (var i = 0; i < finals.length; i++) { |
||||
|
final item = finals[i]; |
||||
|
final start = _offsetMs(item.timestamp, recordingStartedAt, pauses); |
||||
|
// 结束时间取下一句开头;最后一句按字数估一个上限,别让它盖到音频末尾之外 |
||||
|
final int end = i + 1 < finals.length |
||||
|
? _offsetMs(finals[i + 1].timestamp, recordingStartedAt, pauses) |
||||
|
: start + _estimateSpeechMs(item.sourceText); |
||||
|
out.add({ |
||||
|
'content': _content(item), |
||||
|
'starttime': start, |
||||
|
'endtime': end < start ? start : end, |
||||
|
'speaker': twoParty ? _speakerOf(item, ownLanguageCode) : '', |
||||
|
}); |
||||
|
} |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
/// 相对录音起点的毫秒数,扣掉在这句话之前发生过的暂停时长。 |
||||
|
/// 面对面翻译是逐句按住说话、松开就暂停录音的,不扣的话越往后偏得越多。 |
||||
|
int _offsetMs( |
||||
|
DateTime at, DateTime startedAt, List<(DateTime, DateTime)> pauses) { |
||||
|
var ms = at.difference(startedAt).inMilliseconds; |
||||
|
for (final (from, to) in pauses) { |
||||
|
if (!to.isAfter(at)) { |
||||
|
ms -= to.difference(from).inMilliseconds; |
||||
|
} else if (from.isBefore(at)) { |
||||
|
ms -= at.difference(from).inMilliseconds; |
||||
|
} |
||||
|
} |
||||
|
return ms < 0 ? 0 : ms; |
||||
|
} |
||||
|
|
||||
|
/// 按字数估一句话的时长:中文约 4 字/秒,取 1~30 秒。 |
||||
|
int _estimateSpeechMs(String text) { |
||||
|
final ms = text.trim().length * 250; |
||||
|
if (ms < 1000) return 1000; |
||||
|
if (ms > 30000) return 30000; |
||||
|
return ms; |
||||
|
} |
||||
|
|
||||
|
/// 原文一行、译文一行。纪要里两种语言都留着,翻译记录的价值就在对照。 |
||||
|
String _content(TranslationItem item) { |
||||
|
final src = item.sourceText.trim(); |
||||
|
final dst = item.translatedText.trim(); |
||||
|
if (dst.isEmpty || dst == src) return src; |
||||
|
return '$src\n$dst'; |
||||
|
} |
||||
|
|
||||
|
/// 说话人标签:`Speaker_0` = 本端(机主),`Speaker_1` = 对端。 |
||||
|
/// 通话翻译按 AST 的 serviceId 分(A=己方 B=对方),面对面按这句话说的是哪种语言分。 |
||||
|
/// |
||||
|
/// ⚠️ 格式必须与服务端转写一致(`tasks.go` 的 `normalizeSpeakers`)。 |
||||
|
/// 原来这里写的是裸 `0` / `1`,三处后果都不在纪要本身: |
||||
|
/// - 转写页直接把这个字段当文案显示,别的纪要显示 `Speaker_0`,翻译来的显示 `0`; |
||||
|
/// - 会议纪要喂给 LLM 抽待办时 `[0]:` 和 `[Speaker_1]` 混着,抽出来的负责人 |
||||
|
/// 就是 `Speaker2`、`[Speaker_0]` 各种样; |
||||
|
/// - MCP 的 meaningfulPersonnel 只认 `Speaker_` 前缀,裸编号会被当成 |
||||
|
/// 「用户命名过的真人」塞给模型。 |
||||
|
String _speakerOf(TranslationItem item, String ownLanguageCode) { |
||||
|
final sid = item.serviceId.trim().toUpperCase(); |
||||
|
if (sid == 'A') return kSpeakerSelf; |
||||
|
if (sid == 'B') return kSpeakerPeer; |
||||
|
return item.sourceLanguageCode == ownLanguageCode ? kSpeakerSelf : kSpeakerPeer; |
||||
|
} |
||||
|
|
||||
|
/// 说话人标签的两个取值。与服务端 `Speaker_<n>` 同一口径。 |
||||
|
const String kSpeakerSelf = 'Speaker_0'; |
||||
|
const String kSpeakerPeer = 'Speaker_1'; |
||||
@ -0,0 +1,109 @@ |
|||||
|
import AVFoundation |
||||
|
import Foundation |
||||
|
import os.log |
||||
|
|
||||
|
/// 多媒体翻译(音视频翻译)模式下让 App 在后台活着的静音播放器。 |
||||
|
/// |
||||
|
/// 这个模式的音频来自录屏广播扩展(走本地 socket 进来),主 App 自己**没有任何 |
||||
|
/// 真实的音频 I/O**:麦克风被显式停掉、也没有播放器。Info.plist 的 |
||||
|
/// `UIBackgroundModes: audio` 只保活「正在播放或录音」的进程,一个激活着却空转的 |
||||
|
/// AVAudioSession 不算数。于是用户把视频 App 切到全屏、我们退到后台的那一刻进程就被 |
||||
|
/// 挂起,讯飞/Azure 的 WebSocket 被系统掐断,回到前台看到的就是 |
||||
|
/// `Xunfei URLSession failed … [NSPOSIXErrorDomain:53] Software caused connection abort`。 |
||||
|
/// |
||||
|
/// 这里起一台只挂了一个 AVAudioPlayerNode 的引擎,循环播一段全零 buffer、混音器音量 0。 |
||||
|
/// 会话本来就是 `.playAndRecord + .mixWithOthers`(不打断正在播的视频),静音输出对 |
||||
|
/// 用户零感知,但对系统来说进程「正在播放」,后台就不会被挂起。 |
||||
|
/// |
||||
|
/// ⚠️ 不要碰 `engine.inputNode`:一碰就会让引擎去配置输入链路,这个模式下没有输入。 |
||||
|
final class MediaKeepAlivePlayer { |
||||
|
private let log = OSLog(subsystem: "com.azure.speech", category: "MediaKeepAlive") |
||||
|
private let queue = DispatchQueue(label: "azure_speech.media_keepalive") |
||||
|
|
||||
|
private var engine: AVAudioEngine? |
||||
|
private var node: AVAudioPlayerNode? |
||||
|
private var observers: [NSObjectProtocol] = [] |
||||
|
private var wanted = false |
||||
|
|
||||
|
func start() { |
||||
|
queue.async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
self.wanted = true |
||||
|
self.startEngineLocked() |
||||
|
self.installObserversLocked() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func stop() { |
||||
|
queue.async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
self.wanted = false |
||||
|
self.removeObserversLocked() |
||||
|
self.node?.stop() |
||||
|
self.engine?.stop() |
||||
|
self.node = nil |
||||
|
self.engine = nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 内部(都在 queue 上) |
||||
|
|
||||
|
private func startEngineLocked() { |
||||
|
if let e = engine, e.isRunning { return } |
||||
|
node?.stop() |
||||
|
engine?.stop() |
||||
|
|
||||
|
let e = AVAudioEngine() |
||||
|
let n = AVAudioPlayerNode() |
||||
|
e.attach(n) |
||||
|
let fmt = e.mainMixerNode.outputFormat(forBus: 0) |
||||
|
// 0Hz(会话未激活/路由切换瞬间)拿去 connect 是 ObjC 断言,Swift 接不住 |
||||
|
guard fmt.sampleRate > 0, fmt.channelCount > 0 else { |
||||
|
os_log("保活引擎启动失败:混音器输出格式无效", log: log, type: .error) |
||||
|
return |
||||
|
} |
||||
|
e.connect(n, to: e.mainMixerNode, format: fmt) |
||||
|
e.mainMixerNode.outputVolume = 0 |
||||
|
let frames = AVAudioFrameCount(fmt.sampleRate) // 1 秒静音 |
||||
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: frames) else { |
||||
|
os_log("保活引擎启动失败:分配静音缓冲失败", log: log, type: .error) |
||||
|
return |
||||
|
} |
||||
|
buffer.frameLength = frames // 新分配的 buffer 内容即全零 |
||||
|
do { |
||||
|
try e.start() |
||||
|
} catch { |
||||
|
os_log("保活引擎启动失败: %{public}@", log: log, type: .error, error.localizedDescription) |
||||
|
return |
||||
|
} |
||||
|
n.scheduleBuffer(buffer, at: nil, options: .loops, completionHandler: nil) |
||||
|
n.play() |
||||
|
engine = e |
||||
|
node = n |
||||
|
os_log("后台保活引擎已启动", log: log, type: .info) |
||||
|
} |
||||
|
|
||||
|
private func installObserversLocked() { |
||||
|
guard observers.isEmpty else { return } |
||||
|
let center = NotificationCenter.default |
||||
|
// 系统打断(来电/Siri)结束后引擎是停着的,要重新拉起来 |
||||
|
observers.append(center.addObserver(forName: AVAudioSession.interruptionNotification, |
||||
|
object: nil, queue: nil) { [weak self] n in |
||||
|
guard let self = self, |
||||
|
let raw = n.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, |
||||
|
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return } |
||||
|
self.queue.async { if self.wanted { self.startEngineLocked() } } |
||||
|
}) |
||||
|
// 路由/采样率变化时 AVAudioEngine 会自己停掉并发这个通知 |
||||
|
observers.append(center.addObserver(forName: .AVAudioEngineConfigurationChange, |
||||
|
object: nil, queue: nil) { [weak self] _ in |
||||
|
guard let self = self else { return } |
||||
|
self.queue.async { if self.wanted { self.startEngineLocked() } } |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
private func removeObserversLocked() { |
||||
|
observers.forEach { NotificationCenter.default.removeObserver($0) } |
||||
|
observers.removeAll() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
import 'package:eaimar/data/services/deapsound_ai_service.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
/// 后台 llm_qwen.base_url 是不带路径的根地址,客户端要自己补 /chat/completions; |
||||
|
/// 万一运维填了完整地址也不能拼成两段。 |
||||
|
void main() { |
||||
|
Uri build(String base) { |
||||
|
final s = DeapsoundAIService.debugWithBaseUrl(base); |
||||
|
return s.chatUrl; |
||||
|
} |
||||
|
|
||||
|
test('根地址补路径', () { |
||||
|
expect(build('https://dashscope.aliyuncs.com/compatible-mode/v1').toString(), |
||||
|
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'); |
||||
|
}); |
||||
|
test('尾部斜杠不重复', () { |
||||
|
expect(build('https://x/v1/').toString(), 'https://x/v1/chat/completions'); |
||||
|
}); |
||||
|
test('已带完整路径原样用', () { |
||||
|
expect(build('https://x/v1/chat/completions').toString(), |
||||
|
'https://x/v1/chat/completions'); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
import 'package:eaimar/modules/login/models/login_methods.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
void main() { |
||||
|
Set<LoginMethod> ios({bool guest = true}) => |
||||
|
resolveLoginMethods(isIOS: true, envChannel: 'CHINA_XIAOMI', guestVisible: guest); |
||||
|
Set<LoginMethod> google({bool guest = true}) => resolveLoginMethods( |
||||
|
isIOS: false, envChannel: 'GLOBAL_GOOGLE', guestVisible: guest); |
||||
|
Set<LoginMethod> domestic(String ch, {bool guest = true}) => |
||||
|
resolveLoginMethods(isIOS: false, envChannel: ch, guestVisible: guest); |
||||
|
|
||||
|
test('苹果包:只有苹果登录 + 游客', () { |
||||
|
expect(ios(), {LoginMethod.apple, LoginMethod.guest}); |
||||
|
}); |
||||
|
|
||||
|
test('苹果包不受 .env Channel 影响(iOS 不跑 build_channels.sh,值还是国内渠道)', () { |
||||
|
expect( |
||||
|
resolveLoginMethods( |
||||
|
isIOS: true, envChannel: 'GLOBAL_GOOGLE', guestVisible: false), |
||||
|
{LoginMethod.apple}, |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
test('安卓国内包:只有微信登录 + 游客', () { |
||||
|
for (final ch in ['CHINA_XIAOMI', 'CHINA_HUAWEI', 'CHINA_HONOR', 'OFFICIAL']) { |
||||
|
expect(domestic(ch), {LoginMethod.wechat, LoginMethod.guest}, |
||||
|
reason: 'channel=$ch'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
test('谷歌包:只有邮箱(contact)登录 + 游客,没有微信也没有苹果', () { |
||||
|
expect(google(), {LoginMethod.contact, LoginMethod.guest}); |
||||
|
}); |
||||
|
|
||||
|
test('Channel 认不出时按国内包处理(失败方向:宁可留微信,不要露出邮箱登录)', () { |
||||
|
expect(domestic(''), {LoginMethod.wechat, LoginMethod.guest}); |
||||
|
expect(domestic('xiaomi'), {LoginMethod.wechat, LoginMethod.guest}); |
||||
|
}); |
||||
|
|
||||
|
test('Channel 的大小写与空白不影响判定', () { |
||||
|
expect(domestic(' global_google ', guest: false), {LoginMethod.contact}); |
||||
|
}); |
||||
|
|
||||
|
test('游客开关由服务端给,关掉时每个包各自只剩一个入口', () { |
||||
|
expect(ios(guest: false), {LoginMethod.apple}); |
||||
|
expect(google(guest: false), {LoginMethod.contact}); |
||||
|
expect(domestic('CHINA_VIVO', guest: false), {LoginMethod.wechat}); |
||||
|
}); |
||||
|
|
||||
|
test('任何组合下都至少有一个登录入口,且三种包互不重叠', () { |
||||
|
for (final m in [ios(), google(), domestic('CHINA_OPPO')]) { |
||||
|
expect(m.where((e) => e != LoginMethod.guest).length, 1); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
import 'dart:typed_data'; |
||||
|
|
||||
|
import 'package:eaimar/data/services/wav_writer.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
Uint8List _bytes(int n, int fill) => Uint8List.fromList(List.filled(n, fill)); |
||||
|
|
||||
|
void main() { |
||||
|
test('两路顺序到达:逐对取出,谁都不丢', () { |
||||
|
final p = StereoPairer(); |
||||
|
p.pushLeft(_bytes(640, 1)); |
||||
|
expect(p.take(), isNull); // 只有一路时不出对 |
||||
|
p.pushRight(_bytes(640, 2)); |
||||
|
final pair = p.take()!; |
||||
|
expect(pair.left.length, 640); |
||||
|
expect(pair.right.length, 640); |
||||
|
expect(pair.left.first, 1); |
||||
|
expect(pair.right.first, 2); |
||||
|
expect(p.take(), isNull); |
||||
|
}); |
||||
|
|
||||
|
test('对端连来两帧、本端才来一帧:第一帧不再被覆盖丢掉', () { |
||||
|
final p = StereoPairer(); |
||||
|
p.pushRight(_bytes(640, 2)); |
||||
|
p.pushRight(_bytes(640, 3)); |
||||
|
p.pushLeft(_bytes(640, 1)); |
||||
|
final first = p.take()!; |
||||
|
expect(first.right.first, 2); // 老实现这里会拿到 3(第一帧被覆盖) |
||||
|
expect(p.take(), isNull); |
||||
|
p.pushLeft(_bytes(640, 1)); |
||||
|
final second = p.take()!; |
||||
|
expect(second.right.first, 3); |
||||
|
}); |
||||
|
|
||||
|
test('长度不等时按较短者对齐到整样本,余量留到下一次', () { |
||||
|
final p = StereoPairer(); |
||||
|
p.pushLeft(_bytes(1000, 1)); |
||||
|
p.pushRight(_bytes(301, 2)); |
||||
|
final a = p.take()!; |
||||
|
expect(a.left.length, 300); |
||||
|
expect(a.right.length, 300); |
||||
|
p.pushRight(_bytes(699, 2)); |
||||
|
final b = p.take()!; |
||||
|
expect(b.left.length, 700); |
||||
|
expect(b.right.length, 700); |
||||
|
}); |
||||
|
|
||||
|
test('一路长期缺席:另一路只保留最近 maxPending 字节', () { |
||||
|
final p = StereoPairer(maxPending: 1000); |
||||
|
for (var i = 0; i < 5; i++) { |
||||
|
p.pushLeft(_bytes(640, i)); |
||||
|
} |
||||
|
p.pushRight(_bytes(1000, 9)); |
||||
|
final pair = p.take()!; |
||||
|
expect(pair.left.length, 1000); |
||||
|
expect(pair.left.last, 4); // 留下的是最新的那一段 |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
import 'package:eaimar/modules/translation/models/translation_direction.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
const zh = 'zh-CN'; |
||||
|
const en = 'en-US'; |
||||
|
|
||||
|
void main() { |
||||
|
group('单向模式:同声翻译 / 多媒体翻译', () { |
||||
|
for (final mode in ['simultaneous', 'audioVideo']) { |
||||
|
test('$mode 永远是 源→目标,与 ASR 报的语种无关', () { |
||||
|
// ASR 报对了 |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: mode, |
||||
|
sourceCode: zh, |
||||
|
targetCode: en, |
||||
|
detectedLanguageCode: zh), |
||||
|
const TranslationDirection(zh, en), |
||||
|
); |
||||
|
// ASR 没报(空串)—— 老实现在这里会判成反向 |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: mode, sourceCode: zh, targetCode: en), |
||||
|
const TranslationDirection(zh, en), |
||||
|
); |
||||
|
// ASR 报成了目标语言 —— 老实现同样会反向 |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: mode, |
||||
|
sourceCode: zh, |
||||
|
targetCode: en, |
||||
|
detectedLanguageCode: en), |
||||
|
const TranslationDirection(zh, en), |
||||
|
); |
||||
|
// 大小写/地区不一致 |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: mode, |
||||
|
sourceCode: zh, |
||||
|
targetCode: en, |
||||
|
detectedLanguageCode: 'zh'), |
||||
|
const TranslationDirection(zh, en), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
test('换了语言对照样只认源→目标', () { |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: 'simultaneous', sourceCode: en, targetCode: zh), |
||||
|
const TranslationDirection(en, zh), |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
group('面对面仍是双向', () { |
||||
|
test('说话人 1 → 源到目标;说话人 2 → 目标到源', () { |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: 'faceToFace', sourceCode: zh, targetCode: en, speaker: 1), |
||||
|
const TranslationDirection(zh, en), |
||||
|
); |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: 'faceToFace', sourceCode: zh, targetCode: en, speaker: 2), |
||||
|
const TranslationDirection(en, zh), |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
test('说话人未知时才看 ASR 检测结果', () { |
||||
|
expect( |
||||
|
resolveTranslationDirection( |
||||
|
mode: 'faceToFace', |
||||
|
sourceCode: zh, |
||||
|
targetCode: en, |
||||
|
detectedLanguageCode: en), |
||||
|
const TranslationDirection(en, zh), |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('isOneWayTranslationMode 只覆盖这两个模式', () { |
||||
|
expect(isOneWayTranslationMode('simultaneous'), isTrue); |
||||
|
expect(isOneWayTranslationMode('audioVideo'), isTrue); |
||||
|
expect(isOneWayTranslationMode('faceToFace'), isFalse); |
||||
|
expect(isOneWayTranslationMode('call'), isFalse); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,117 @@ |
|||||
|
import 'package:eaimar/modules/translation/models/translation_export.dart'; |
||||
|
import 'package:eaimar/modules/translation/models/translation_models.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
TranslationItem _item( |
||||
|
DateTime at, { |
||||
|
String src = '你好', |
||||
|
String dst = 'Hello', |
||||
|
String srcLang = 'zh-CN', |
||||
|
String serviceId = '', |
||||
|
bool intermediate = false, |
||||
|
}) => |
||||
|
TranslationItem( |
||||
|
sourceText: src, |
||||
|
translatedText: dst, |
||||
|
sourceLanguageCode: srcLang, |
||||
|
targetLanguageCode: srcLang == 'zh-CN' ? 'en-US' : 'zh-CN', |
||||
|
timestamp: at, |
||||
|
sessionId: 's', |
||||
|
serviceId: serviceId, |
||||
|
isIntermediate: intermediate, |
||||
|
); |
||||
|
|
||||
|
void main() { |
||||
|
final t0 = DateTime(2026, 9, 18, 10, 0, 0); |
||||
|
|
||||
|
test('只取录音开始之后的整句,时间是相对录音起点的毫秒', () { |
||||
|
final out = buildMeetingTranscript( |
||||
|
items: [ |
||||
|
_item(t0.subtract(const Duration(seconds: 5)), src: '上一轮的'), |
||||
|
_item(t0.add(const Duration(seconds: 2))), |
||||
|
_item(t0.add(const Duration(seconds: 6)), src: '半句', intermediate: true), |
||||
|
_item(t0.add(const Duration(seconds: 9)), src: '第二句', dst: 'Second'), |
||||
|
], |
||||
|
recordingStartedAt: t0, |
||||
|
mode: 'simultaneous', |
||||
|
ownLanguageCode: 'zh-CN', |
||||
|
); |
||||
|
expect(out.length, 2); |
||||
|
expect(out[0]['starttime'], 2000); |
||||
|
expect(out[0]['endtime'], 9000); // 下一句开头 |
||||
|
expect(out[0]['content'], '你好\nHello'); |
||||
|
expect(out[0]['speaker'], ''); // 同传单向,不标说话人 |
||||
|
expect(out[1]['starttime'], 9000); |
||||
|
expect(out[1]['endtime'], greaterThan(9000)); // 最后一句按字数估 |
||||
|
}); |
||||
|
|
||||
|
test('通话翻译按 AST 的 serviceId 分说话人:A=0 本端 B=1 对端', () { |
||||
|
final out = buildMeetingTranscript( |
||||
|
items: [ |
||||
|
_item(t0.add(const Duration(seconds: 1)), serviceId: 'A'), |
||||
|
_item(t0.add(const Duration(seconds: 3)), |
||||
|
src: 'Hi', dst: '嗨', srcLang: 'en-US', serviceId: 'B'), |
||||
|
], |
||||
|
recordingStartedAt: t0, |
||||
|
mode: 'call', |
||||
|
ownLanguageCode: 'zh-CN', |
||||
|
); |
||||
|
expect(out.map((e) => e['speaker']), ['Speaker_0', 'Speaker_1']); |
||||
|
}); |
||||
|
|
||||
|
test('面对面没有 serviceId,按这句话的语言分:说源语言的是本端', () { |
||||
|
final out = buildMeetingTranscript( |
||||
|
items: [ |
||||
|
_item(t0.add(const Duration(seconds: 1))), |
||||
|
_item(t0.add(const Duration(seconds: 3)), |
||||
|
src: 'Hi', dst: '嗨', srcLang: 'en-US'), |
||||
|
], |
||||
|
recordingStartedAt: t0, |
||||
|
mode: 'faceToFace', |
||||
|
ownLanguageCode: 'zh-CN', |
||||
|
); |
||||
|
expect(out.map((e) => e['speaker']), ['Speaker_0', 'Speaker_1']); |
||||
|
}); |
||||
|
|
||||
|
test('暂停区间要从偏移里扣掉(面对面逐句松手即暂停)', () { |
||||
|
final out = buildMeetingTranscript( |
||||
|
items: [ |
||||
|
_item(t0.add(const Duration(seconds: 2))), |
||||
|
// 3s~13s 暂停了 10 秒,这句在 15s 说的,音频里其实是第 5 秒 |
||||
|
_item(t0.add(const Duration(seconds: 15)), src: '第二句', dst: ''), |
||||
|
], |
||||
|
recordingStartedAt: t0, |
||||
|
mode: 'faceToFace', |
||||
|
ownLanguageCode: 'zh-CN', |
||||
|
pauses: [ |
||||
|
(t0.add(const Duration(seconds: 3)), t0.add(const Duration(seconds: 13))) |
||||
|
], |
||||
|
); |
||||
|
expect(out[0]['starttime'], 2000); |
||||
|
expect(out[0]['endtime'], 5000); |
||||
|
expect(out[1]['starttime'], 5000); |
||||
|
expect(out[1]['content'], '第二句'); // 没译文就只有原文一行 |
||||
|
}); |
||||
|
|
||||
|
test('译文与原文相同时不重复', () { |
||||
|
final out = buildMeetingTranscript( |
||||
|
items: [_item(t0.add(const Duration(seconds: 1)), src: 'OK', dst: 'OK')], |
||||
|
recordingStartedAt: t0, |
||||
|
mode: 'simultaneous', |
||||
|
ownLanguageCode: 'zh-CN', |
||||
|
); |
||||
|
expect(out.single['content'], 'OK'); |
||||
|
}); |
||||
|
|
||||
|
test('没有整句就返回空,调用方据此退回普通录音归档', () { |
||||
|
expect( |
||||
|
buildMeetingTranscript( |
||||
|
items: [_item(t0.add(const Duration(seconds: 1)), src: ' ')], |
||||
|
recordingStartedAt: t0, |
||||
|
mode: 'call', |
||||
|
ownLanguageCode: 'zh-CN', |
||||
|
), |
||||
|
isEmpty, |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
Loading…
Reference in new issue