import 'translation_models.dart'; /// 把一段翻译会话的条目转成语音纪要的转写段。 /// /// 输出形状与服务端 `ContextStruct`(`echomeet_addrecord` 的 `original`) /// 和本地 `meetingspeaker` 表一致:`content / starttime / endtime / speaker`, /// 时间是**相对录音起点的毫秒**——纪要详情页拿它做 seekTo,写成绝对时间戳 /// 会让点一句跳到音频末尾之外。 /// /// 纯函数,不碰 controller,见 `test/translation_export_test.dart`。 List> buildMeetingTranscript({ required List 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 = >[]; 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_` 同一口径。 const String kSpeakerSelf = 'Speaker_0'; const String kSpeakerPeer = 'Speaker_1';