Fix realtime ASR preview duplication

This commit is contained in:
Xulilong 2026-07-16 10:27:30 +08:00
parent 3e4c34e474
commit eff0013154
5 changed files with 250 additions and 96 deletions

View File

@ -30,7 +30,8 @@ LLM_ROLE_HISTORY_MAX_CHARS=1200
# ASR voiceprint adapter
VOICE_DETECTED_URL=http://voice-dected:8000
VOICEPRINT_STORE=/data/asr_voiceprints.json
VOICEPRINT_MATCH_THRESHOLD=0.45
VOICEPRINT_MATCH_THRESHOLD=0.55
VOICEPRINT_MATCH_MARGIN=0.08
REALTIME_PARTIAL_MIN_SECONDS=1.5
REALTIME_PARTIAL_INTERVAL_SECONDS=2.0

View File

@ -153,6 +153,7 @@ REALTIME_VOICEPRINT_CHANNELS = int(get_env("REALTIME_VOICEPRINT_CHANNELS", "1"))
REALTIME_VOICEPRINT_SAMPLE_WIDTH = int(get_env("REALTIME_VOICEPRINT_SAMPLE_WIDTH", "2"))
VOICEPRINT_STORE = get_env("VOICEPRINT_STORE", "/data/asr/asr_voiceprints.json")
VOICEPRINT_MATCH_THRESHOLD = float(get_env("VOICEPRINT_MATCH_THRESHOLD", "0.45"))
VOICEPRINT_MATCH_MARGIN = float(get_env("VOICEPRINT_MATCH_MARGIN", "0.08"))
SPEAKER_DET_URL = get_env("SPEAKER_DET_URL", "http://voice-dected:8000/extract_embedding")
# ========================
@ -637,6 +638,8 @@ def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
"start": None,
"end": None,
}
if data.get("speaker_ref"):
segment["speaker_ref"] = data["speaker_ref"]
msg = {
"mode": "offline-speaker",
"type": "asr_with_speaker",
@ -648,6 +651,8 @@ def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
"is_final": data.get("is_final", True),
"segments": [segment],
}
if data.get("speaker_ref"):
msg["speaker_ref"] = data["speaker_ref"]
if "timestamp" in data:
msg["timestamp"] = data["timestamp"]
if "sentence_info" in data:
@ -669,6 +674,8 @@ def build_voiceprint_speaker_message(data: Dict, speaker: str, score: float) ->
"start": None,
"end": None,
}
if data.get("speaker_ref"):
segment["speaker_ref"] = data["speaker_ref"]
msg = {
"mode": "offline-speaker",
"type": "asr_with_speaker",
@ -680,6 +687,8 @@ def build_voiceprint_speaker_message(data: Dict, speaker: str, score: float) ->
"is_final": data.get("is_final", True),
"segments": [segment],
}
if data.get("speaker_ref"):
msg["speaker_ref"] = data["speaker_ref"]
if "timestamp" in data:
msg["timestamp"] = data["timestamp"]
return msg
@ -828,17 +837,35 @@ class VoiceprintMatcher:
best_name = None
best_score = -1.0
second_score = -1.0
for name, profile_embedding in profiles.items():
score = self._cosine(embedding, profile_embedding)
if score > best_score:
second_score = best_score
best_name = name
best_score = score
elif score > second_score:
second_score = score
if best_name and best_score >= self.match_threshold:
logger.info("实时声纹匹配: %s score=%.4f", best_name, best_score)
score_margin = best_score - second_score
if best_name and best_score >= self.match_threshold and score_margin >= VOICEPRINT_MATCH_MARGIN:
logger.info(
"实时声纹匹配: %s score=%.4f margin=%.4f",
best_name,
best_score,
score_margin,
)
return {"speaker": best_name, "score": best_score}
logger.info("实时声纹未匹配: best=%s score=%.4f threshold=%.4f", best_name, best_score, self.match_threshold)
logger.info(
"实时声纹未匹配: best=%s score=%.4f second=%.4f margin=%.4f threshold=%.4f required_margin=%.4f",
best_name,
best_score,
second_score,
score_margin,
self.match_threshold,
VOICEPRINT_MATCH_MARGIN,
)
return None
@ -1133,17 +1160,28 @@ async def forward_audio(websocket):
"""
try:
async for message in target_ws:
# 1. 透传 ASR 原始结果到前端(字幕显示)
data = None
forward_message = message
if isinstance(message, str):
try:
data = json.loads(message)
except json.JSONDecodeError:
data = None
if isinstance(data, dict) and data.get("mode") == "2pass-offline":
data = dict(data)
data.setdefault("speaker_ref", uuid.uuid4().hex)
forward_message = json.dumps(data, ensure_ascii=False)
# 1. 透传 ASR 结果到前端(字幕显示)
try:
await websocket.send(message)
await websocket.send(forward_message)
except websockets.exceptions.ConnectionClosed:
logger.info("客户端连接已关闭,停止下行转发")
break
# 2. 如果是文本消息,尝试触发 Agent 处理
if isinstance(message, str):
if isinstance(data, dict):
try:
data = json.loads(message)
# 只处理最终识别结果2pass-offline 模式)
if data.get("mode") == "2pass-offline":
speaker_msg = build_acoustic_speaker_message(data)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -621,7 +621,7 @@ export class RealtimeAsrPipeline {
// FunASR timestamp 可能是 JSON 字符串,也可能已经是数组。这里取最后一段
// 结束时间,用于去重、旧消息过滤和后续说话人对齐。
extractTimestampMs(timestampPayload) {
extractTimestampMs(timestampPayload) {
if (!timestampPayload) {
return null;
}
@ -645,11 +645,116 @@ export class RealtimeAsrPipeline {
lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]);
return Number.isFinite(endOrStart) ? endOrStart : null;
} catch (_) {
return null;
}
}
// 手动编辑前获取最新 ASR 时间点,用它建立旧消息屏障。
return null;
}
}
getSpeakerRef(parsedMessage) {
return parsedMessage?.speaker_ref || parsedMessage?.speakerRef || "";
}
normalizeSpeakerMatchText(text) {
return (text || "").replace(/\s+/g, "").replace(/[。!?!?,、;;:.]/g, "");
}
getCommittedRealtimeRawText() {
const segmentText = this.segmentList
.filter((item) => item.epoch === this.liveTranscriptionEpoch)
.map((item) => item.sourceText || item.text || "")
.join("");
const pendingText = this.pendingSentenceQueue
.filter((item) => item.epoch === this.liveTranscriptionEpoch)
.map((item) => item.sourceText || item.rawText || item.correctedText || "")
.join("");
return segmentText + pendingText;
}
trimCommittedRealtimePrefix(incomingText) {
let text = incomingText || "";
const committedText = this.getCommittedRealtimeRawText();
if (!text || committedText.length < 4) {
return text;
}
const maxOverlapLength = Math.min(180, committedText.length, text.length);
for (let size = maxOverlapLength; size >= 4; size--) {
const overlapText = committedText.slice(-size);
const matchIndex = text.lastIndexOf(overlapText);
if (matchIndex !== -1) {
text = text.slice(matchIndex + overlapText.length);
break;
}
}
return text;
}
mergeOnlinePreviewText(incomingText, parsedMessage, shouldBreak) {
let text = this.trimCommittedRealtimePrefix(incomingText || "");
if (!text.trim()) {
this.recTextOnline = "";
return;
}
const currentText = this.trimCommittedRealtimePrefix(this.recTextOnline || "");
const normalizedCurrent = this.normalizeSpeakerMatchText(currentText);
const normalizedIncoming = this.normalizeSpeakerMatchText(text);
if (
normalizedCurrent &&
normalizedIncoming &&
normalizedCurrent.includes(normalizedIncoming) &&
!normalizedIncoming.includes(normalizedCurrent)
) {
this.recTextOnline = currentText;
return;
}
const shouldReplace =
parsedMessage.is_full_text === true ||
!currentText.trim() ||
(normalizedCurrent && normalizedIncoming.includes(normalizedCurrent));
if (shouldReplace) {
this.recTextOnline = shouldBreak ? text + "\n" : text;
return;
}
let appendText = text;
const maxOverlapLength = Math.min(80, currentText.length, text.length);
for (let size = maxOverlapLength; size >= 2; size--) {
if (currentText.endsWith(text.slice(0, size))) {
appendText = text.slice(size);
break;
}
}
this.recTextOnline = currentText + (shouldBreak ? appendText + "\n" : appendText);
}
speakerMessageMatchesItem(item, messageTimestampMs, messageText, speakerRef) {
if (!item) {
return false;
}
if (speakerRef || item.speakerRef) {
return Boolean(speakerRef && item.speakerRef && speakerRef === item.speakerRef);
}
if (Number.isFinite(messageTimestampMs) && Number.isFinite(item.timestampMs)) {
return item.timestampMs === messageTimestampMs;
}
const normalizedMessageText = this.normalizeSpeakerMatchText(messageText);
const normalizedItemText = this.normalizeSpeakerMatchText(
item.sourceText || item.rawText || item.text || ""
);
return Boolean(
normalizedMessageText &&
normalizedItemText &&
normalizedMessageText === normalizedItemText
);
}
// 手动编辑前获取最新 ASR 时间点,用它建立旧消息屏障。
getLatestRealtimeTimestamp() {
const latestSegmentTimestamp = this.segmentList.reduce(
(latest, segment) =>
@ -749,11 +854,12 @@ export class RealtimeAsrPipeline {
}
// 预览文本 = 待矫正 offline 文本 + online/offline 交接文本 + 当前 online 临时文本。
syncRealtimePreview() {
// 【兜底去重】无论前面逻辑是否产生重复标点,最终渲染前统一清理
syncRealtimePreview() {
// 【兜底去重】无论前面逻辑是否产生重复标点,最终渲染前统一清理
this.recTextOnline = this.trimCommittedRealtimePrefix(this.recTextOnline);
const previewText = this._deduplicateConsecutivePunctuation(this.recTextOnline);
this.onPreviewTextChange(previewText);
}
this.onPreviewTextChange(previewText);
}
// 幂等查找:同一 source/timestamp 的重复 websocket 回包不能触发第二次矫正,
// 也不能重复落到页面。
@ -779,7 +885,7 @@ export class RealtimeAsrPipeline {
// 2pass-offline 文本进入 LLM 矫正前的唯一入口。这里负责旧消息过滤、
// 重复回包去重、编辑重叠裁剪,以及创建 pending 队列项。
ensurePendingSentence(sourceText, pendingTimestamp, messageEpoch) {
ensurePendingSentence(sourceText, pendingTimestamp, messageEpoch, speakerRef = "") {
const timestampMs = this.extractTimestampMs(pendingTimestamp);
if (this.isStaleRealtimeMessage(timestampMs, messageEpoch)) {
return null;
@ -790,10 +896,13 @@ export class RealtimeAsrPipeline {
sourceText,
pendingTimestamp,
messageEpoch
);
if (existingItem) {
return existingItem;
}
);
if (existingItem) {
if (speakerRef && !existingItem.speakerRef) {
existingItem.speakerRef = speakerRef;
}
return existingItem;
}
const trimmedText = this.trimOfflineTextWithCarryover(sourceText || "");
if (!trimmedText) {
@ -810,10 +919,11 @@ export class RealtimeAsrPipeline {
timestamp: pendingTimestamp || "",
timestampMs,
correctedText: "",
processed: false,
correctionRequested: false,
epoch: messageEpoch,
};
processed: false,
correctionRequested: false,
speakerRef,
epoch: messageEpoch,
};
// 按 timestampMs 顺序插入,保持队列按音频时间有序
// 这样 flush 时按时间顺序处理,预览也按说话顺序显示
@ -854,6 +964,7 @@ export class RealtimeAsrPipeline {
timestamp: item.timestamp,
timestampMs: item.timestampMs,
speaker: item.speaker || null,
speakerRef: item.speakerRef || "",
processed: true,
epoch: item.epoch,
};
@ -983,13 +1094,14 @@ export class RealtimeAsrPipeline {
id: this.pendingSentenceSequence++,
sourceText: remainingOnlineText,
rawText: remainingOnlineText,
timestamp: "",
timestampMs: fallbackTimestampMs,
correctedText: "",
processed: false,
correctionRequested: true,
epoch: messageEpoch,
};
timestamp: "",
timestampMs: fallbackTimestampMs,
correctedText: "",
processed: false,
correctionRequested: true,
speakerRef: "",
epoch: messageEpoch,
};
this.pendingSentenceQueue.push(pendingItem);
this.setPendingCorrectionCount(this.pendingCorrectionCount + 1);
this._requestCorrection(pendingItem);
@ -1192,6 +1304,13 @@ export class RealtimeAsrPipeline {
return;
}
const speakerRef = this.getSpeakerRef(parsedMessage);
const messageText =
parsedMessage.text ||
(parsedMessage.segments &&
parsedMessage.segments[0] &&
parsedMessage.segments[0].text) ||
"";
const speaker =
(parsedMessage.segments &&
parsedMessage.segments[0] &&
@ -1205,11 +1324,7 @@ export class RealtimeAsrPipeline {
if (!pending || pending.epoch !== messageEpoch || pending.speaker) {
continue;
}
if (
Number.isFinite(messageTimestampMs) &&
Number.isFinite(pending.timestampMs) &&
pending.timestampMs !== messageTimestampMs
) {
if (!this.speakerMessageMatchesItem(pending, messageTimestampMs, messageText, speakerRef)) {
continue;
}
pending.speaker = speaker;
@ -1225,16 +1340,11 @@ export class RealtimeAsrPipeline {
}
if (
segment.speaker === null &&
Number.isFinite(messageTimestampMs) &&
Number.isFinite(segment.timestampMs) &&
segment.timestampMs === messageTimestampMs
this.speakerMessageMatchesItem(segment, messageTimestampMs, messageText, speakerRef)
) {
targetIndex = i;
break;
}
if (targetIndex === -1 && segment.speaker === null) {
targetIndex = i;
}
}
if (targetIndex !== -1) {
@ -1257,9 +1367,10 @@ export class RealtimeAsrPipeline {
const parsedMessage = JSON.parse(event.data);
const text = "" + parsedMessage.text;
const mode = parsedMessage.mode;
const timestamp = parsedMessage.timestamp;
const messageEpoch = this.liveTranscriptionEpoch;
const mode = parsedMessage.mode;
const timestamp = parsedMessage.timestamp;
const speakerRef = this.getSpeakerRef(parsedMessage);
const messageEpoch = this.liveTranscriptionEpoch;
if (mode === "2pass-online" || mode === "online") {
// 低延迟预览文本可能不完整,因此不进入正式 segmentList。
@ -1277,12 +1388,8 @@ export class RealtimeAsrPipeline {
}
}
if (parsedMessage.is_full_text) {
this.recTextOnline = shouldBreak ? text + "\n" : text;
} else {
this.recTextOnline += shouldBreak ? text + "\n" : text;
}
this.lastOnlineTimestamp = currentTimestamp;
this.mergeOnlinePreviewText(text, parsedMessage, shouldBreak);
this.lastOnlineTimestamp = currentTimestamp;
} else if (mode === "2pass-offline" || mode === "offline") {
// online 通道也可能回显 offline 句子,这里只作为交接预览保存。
// 正式句子只通过 offline 通道进入矫正流程。
@ -1290,8 +1397,12 @@ export class RealtimeAsrPipeline {
const pendingSentence = this.ensurePendingSentence(
text,
timestamp,
messageEpoch
messageEpoch,
speakerRef
);
this.recTextOnline = "";
this.offlineSentenceHandoffText = "";
this.lastOnlineTimestamp = null;
if (
pendingSentence &&
!pendingSentence.processed &&
@ -1316,10 +1427,11 @@ export class RealtimeAsrPipeline {
return;
}
const parsedMessage = JSON.parse(event.data);
const mode = parsedMessage.mode;
const timestamp = parsedMessage.timestamp;
const messageEpoch = this.liveTranscriptionEpoch;
const parsedMessage = JSON.parse(event.data);
const mode = parsedMessage.mode;
const timestamp = parsedMessage.timestamp;
const speakerRef = this.getSpeakerRef(parsedMessage);
const messageEpoch = this.liveTranscriptionEpoch;
const messageTimestampMs = this.extractTimestampMs(timestamp);
if (mode === "2pass-offline" || mode === "offline") {
@ -1334,13 +1446,16 @@ export class RealtimeAsrPipeline {
this.syncRealtimePreview();
return;
}
const pendingSentence = this.ensurePendingSentence(
text,
timestamp,
messageEpoch
);
this.offlineSentenceHandoffText = "";
this.syncRealtimePreview();
const pendingSentence = this.ensurePendingSentence(
text,
timestamp,
messageEpoch,
speakerRef
);
this.recTextOnline = "";
this.offlineSentenceHandoffText = "";
this.lastOnlineTimestamp = null;
this.syncRealtimePreview();
if (
!pendingSentence ||