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

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