You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
98 lines
4.2 KiB
98 lines
4.2 KiB
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';
|
|
|