调整测试界面气泡渲染逻辑

main
Bifang 2026-09-30 11:04:37 +08:00
parent c07a51a010
commit b9fa3e4d5c
2 changed files with 85 additions and 78 deletions

View File

@ -415,8 +415,8 @@ class Qwen3ASRService:
sentence_text: Optional[str] = None, sentence_text: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
speaker_id = self._tencent_speaker_id(payload.get("speaker_id")) speaker_id = self._tencent_speaker_id(payload.get("speaker_id"))
if payload.get("speaker_pending"): # Keep a provisional cluster ID visible while later segments refine it.
speaker_id = -1 # A genuinely unresolved segment already carries speaker_id=-1.
sentence = { sentence = {
"sentence_id": int(payload.get("index", 0)), "sentence_id": int(payload.get("index", 0)),
"sentence_type": int(sentence_type), "sentence_type": int(sentence_type),
@ -2435,10 +2435,10 @@ class Qwen3ASRService:
stable = self._is_stable_speaker_info(speaker_info) stable = self._is_stable_speaker_info(speaker_info)
speaker_info["speaker_pending"] = not stable speaker_info["speaker_pending"] = not stable
cluster_index = speaker_info.get("_cluster_index") cluster_index = speaker_info.get("_cluster_index")
has_candidate_speaker = speaker_info.get("speaker_id") not in (None, "", -1, "-1")
if ( if (
stable has_candidate_speaker
and and not speaker_info.get("registry_speaker_id")
not speaker_info.get("registry_speaker_id")
and not speaker_info.get("user_id") and not speaker_info.get("user_id")
): ):
safe_cluster_index = self._safe_int(cluster_index) safe_cluster_index = self._safe_int(cluster_index)
@ -2461,10 +2461,6 @@ class Qwen3ASRService:
): ):
ctx.speaker_display_map[safe_cluster_index] = str(speaker_info["speaker_id"]) ctx.speaker_display_map[safe_cluster_index] = str(speaker_info["speaker_id"])
if not stable:
speaker_info["speaker_id"] = -1
speaker_info["speaker_name"] = ""
return speaker_info return speaker_info
async def _resolve_and_emit_segment_speaker( async def _resolve_and_emit_segment_speaker(
@ -2505,8 +2501,6 @@ class Qwen3ASRService:
segment_end_ms=segment_end_ms, segment_end_ms=segment_end_ms,
speaker_info=speaker_info, speaker_info=speaker_info,
) )
if before_view == after_view:
return
queue_backlog = ctx.speaker_job_queue.qsize() queue_backlog = ctx.speaker_job_queue.qsize()
updates: List[Dict[str, Any]] = [] updates: List[Dict[str, Any]] = []
if queue_backlog <= 1: if queue_backlog <= 1:
@ -2520,6 +2514,9 @@ class Qwen3ASRService:
int(updated["segment_index"]), int(updated["segment_index"]),
) )
return return
# Re-clustering may resolve pending IDs even if this segment's
# immediate speaker fields did not change.
if before_view != after_view:
await self._emit_speaker_update(websocket, ctx, task_id, segment_index) await self._emit_speaker_update(websocket, ctx, task_id, segment_index)
except Exception as exc: except Exception as exc:
logger.warning("[%s] Async speaker resolution failed for segment %s: %s", task_id, segment_index, exc) logger.warning("[%s] Async speaker resolution failed for segment %s: %s", task_id, segment_index, exc)

View File

@ -1727,6 +1727,24 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
/* Match Asr-demo: alternate speaker lanes and keep unresolved speech neutral. */
.bubble-row.speaker-left { justify-content: flex-start; }
.bubble-row.speaker-right { justify-content: flex-end; }
.speaker-right .bubble-header { justify-content: flex-end; }
.speaker-right .bubble-body {
border-top-left-radius: 12px;
border-top-right-radius: 4px;
text-align: right;
}
.bubble-row.speaker-unknown .bubble-body {
background: #f0f0f0;
color: #999;
font-style: italic;
border-radius: 12px;
border-top-left-radius: 4px;
}
.speaker-badge.speaker-color-unknown { background-color: #ccc; }
.bubble-wrapper { .bubble-wrapper {
max-width: min(78%, 760px); max-width: min(78%, 760px);
} }
@ -3920,18 +3938,13 @@ ${transcript}
function formatLiveSpeaker(meta) { function formatLiveSpeaker(meta) {
if (!meta) return ""; if (!meta) return "";
const rawName = meta.speaker_name ?? meta.speaker_id ?? "";
const numericSpeakerId = Number(meta.speaker_id); const numericSpeakerId = Number(meta.speaker_id);
if (Number.isFinite(numericSpeakerId) && numericSpeakerId < 0 && !meta.speaker_name) { const name = String(meta.speaker_name || "").trim();
return ""; if (name) return name;
if (Number.isFinite(numericSpeakerId) && numericSpeakerId >= 0) {
return `说话人 ${numericSpeakerId}`;
} }
const name = String(rawName).trim(); return typeof meta.speaker_id === "string" ? meta.speaker_id.trim() : "";
if (!name) return "";
const confidence = Number(meta.speaker_confidence);
if (Number.isFinite(confidence) && confidence > 0) {
return `${name} (${confidence.toFixed(2)})`;
}
return name;
} }
function buildLiveSpeakerKey(meta, fallbackSegmentIndex = null) { function buildLiveSpeakerKey(meta, fallbackSegmentIndex = null) {
@ -3940,7 +3953,6 @@ ${transcript}
} }
if (meta.user_id) return `user:${String(meta.user_id).trim()}`; if (meta.user_id) return `user:${String(meta.user_id).trim()}`;
if (meta.registry_speaker_id) return `registry:${String(meta.registry_speaker_id).trim()}`; if (meta.registry_speaker_id) return `registry:${String(meta.registry_speaker_id).trim()}`;
if (meta.speaker_name) return `name:${String(meta.speaker_name).trim()}`;
const numericSpeakerId = Number(meta.speaker_id); const numericSpeakerId = Number(meta.speaker_id);
if (Number.isFinite(numericSpeakerId) && numericSpeakerId >= 0) { if (Number.isFinite(numericSpeakerId) && numericSpeakerId >= 0) {
return `speaker:${numericSpeakerId}`; return `speaker:${numericSpeakerId}`;
@ -3948,9 +3960,20 @@ ${transcript}
if (typeof meta.speaker_id === "string" && meta.speaker_id.trim()) { if (typeof meta.speaker_id === "string" && meta.speaker_id.trim()) {
return `speaker:${meta.speaker_id.trim()}`; return `speaker:${meta.speaker_id.trim()}`;
} }
if (meta.speaker_name) return `name:${String(meta.speaker_name).trim()}`;
return fallbackSegmentIndex === null ? "unknown" : `segment-${fallbackSegmentIndex}`; return fallbackSegmentIndex === null ? "unknown" : `segment-${fallbackSegmentIndex}`;
} }
function formatLiveTimeRange(startMs, endMs) {
const formatTime = (value) => {
const totalSeconds = Math.max(0, Math.floor(Number(value || 0) / 1000));
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
const seconds = String(totalSeconds % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
};
return `${formatTime(startMs)} - ${formatTime(endMs)}`;
}
function getLiveSpeakerOrder(key) { function getLiveSpeakerOrder(key) {
const safeKey = String(key ?? "unknown").trim() || "unknown"; const safeKey = String(key ?? "unknown").trim() || "unknown";
if (!(safeKey in state.liveSpeakerOrderMap)) { if (!(safeKey in state.liveSpeakerOrderMap)) {
@ -3980,14 +4003,17 @@ ${transcript}
const isPartial = Number(sentence?.sentence_type ?? 0) === 0; const isPartial = Number(sentence?.sentence_type ?? 0) === 0;
const speakerLabel = formatLiveSpeaker(sentence); const speakerLabel = formatLiveSpeaker(sentence);
const speakerKey = buildLiveSpeakerKey(sentence, segmentIndex); const speakerKey = buildLiveSpeakerKey(sentence, segmentIndex);
const speakerPending = Number(sentence?.speaker_id) < 0 && !speakerLabel;
addLiveEntry( addLiveEntry(
`${isPartial ? "进行中" : "已确认"} Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`, `${isPartial ? "进行中" : "已确认"} Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`,
(sentence?.sentence || "").trim(), (sentence?.sentence || "").trim(),
isPartial, isPartial,
speakerLabel, speakerLabel,
Number(sentence?.speaker_id) < 0, speakerPending,
segmentIndex, segmentIndex,
speakerKey, speakerKey,
Number(sentence?.start_time ?? 0),
Number(sentence?.end_time ?? 0),
); );
}); });
const confirmedCount = state.liveTranscript.filter((item) => !item.partial).length; const confirmedCount = state.liveTranscript.filter((item) => !item.partial).length;
@ -4010,6 +4036,8 @@ ${transcript}
text: sentence?.sentence || "", text: sentence?.sentence || "",
speaker_id: sentence?.speaker_id, speaker_id: sentence?.speaker_id,
speaker_name: sentence?.speaker_name, speaker_name: sentence?.speaker_name,
start_time: sentence?.start_time,
end_time: sentence?.end_time,
})), })),
"", "",
sentences.length ? Number(sentences[sentences.length - 1]?.sentence_id ?? 0) : 0, sentences.length ? Number(sentences[sentences.length - 1]?.sentence_id ?? 0) : 0,
@ -4036,14 +4064,17 @@ ${transcript}
const segmentIndex = Number(segment?.index ?? idx); const segmentIndex = Number(segment?.index ?? idx);
const speakerLabel = formatLiveSpeaker(segment); const speakerLabel = formatLiveSpeaker(segment);
const speakerKey = buildLiveSpeakerKey(segment, segmentIndex); const speakerKey = buildLiveSpeakerKey(segment, segmentIndex);
const speakerPending = Number(segment?.speaker_id) < 0 && !speakerLabel;
return { return {
label: `已确认 Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`, label: `已确认 Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`,
text: (segment?.text || "").trim(), text: (segment?.text || "").trim(),
partial: false, partial: false,
speaker: speakerLabel, speaker: speakerLabel,
speakerPending: Number(segment?.speaker_id) < 0, speakerPending,
segmentIndex, segmentIndex,
speakerKey, speakerKey,
startTimeMs: Number(segment?.start_time ?? 0),
endTimeMs: Number(segment?.end_time ?? 0),
}; };
}).filter((item) => item.text); }).filter((item) => item.text);
@ -4070,72 +4101,43 @@ ${transcript}
$("liveTranscript").innerHTML = `<div class="empty-state">点击“开始实时会议”后,这里会像 demo 一样滚动显示气泡和实时变化。</div>`; $("liveTranscript").innerHTML = `<div class="empty-state">点击“开始实时会议”后,这里会像 demo 一样滚动显示气泡和实时变化。</div>`;
return; return;
} }
const lastItem = items[items.length - 1]; const rendered = items.map((item) => {
const pendingItem = lastItem?.partial ? lastItem : null; const speaker = (item.speaker || "").trim();
const baseItems = pendingItem ? items.slice(0, -1) : items; const speakerPending = item.speakerPending || !speaker;
const timeRange = formatLiveTimeRange(item.startTimeMs, item.endTimeMs);
if (!baseItems.length && pendingItem) { const bodyText = `${escapeHtml(item.text || "")}${item.partial ? " ..." : ""}`;
const identity = (pendingItem.speaker || pendingItem.label || "进行中").trim() || "进行中"; if (speakerPending) {
const order = getLiveSpeakerOrder(pendingItem.speakerKey || identity); return `
const side = order % 2 === 0 ? "speaker-left" : "speaker-right"; <article class="bubble-row speaker-left speaker-unknown">
$("liveTranscript").innerHTML = `
<article class="bubble-row ${side} speaker-unknown">
<div class="bubble-wrapper"> <div class="bubble-wrapper">
<div class="bubble-header"> <div class="bubble-header">
<span class="speaker-badge speaker-color-${order}"></span> <span class="speaker-badge speaker-color-unknown"></span>
<span class="speaker-name">${escapeHtml(identity)}</span> <span class="speaker-name">说话人不确定</span>
<span class="bubble-time">${escapeHtml(timeRange)}</span>
</div> </div>
<div class="bubble-body interim">${escapeHtml(pendingItem.text || "")}</div> <div class="bubble-body ${item.partial ? "interim" : ""}">${bodyText}</div>
</div> </div>
</article> </article>
`; `;
$("liveTranscript").scrollTop = $("liveTranscript").scrollHeight;
return;
} }
const rendered = baseItems.map((item) => { const order = getLiveSpeakerOrder(item.speakerKey || speaker);
const speaker = (item.speaker || "").trim();
const label = (item.label || "").trim();
const identity = item.speakerPending ? "未确认说话人" : (speaker || label || "系统");
const order = getLiveSpeakerOrder(item.speakerKey || identity);
const side = order % 2 === 0 ? "speaker-left" : "speaker-right"; const side = order % 2 === 0 ? "speaker-left" : "speaker-right";
const badge = `speaker-color-${order}`; const badge = `speaker-color-${order}`;
const headerMain = item.speakerPending ? "未确认说话人" : (speaker || label || "系统");
const headerMeta = item.speakerPending
? "待后续句子确认归属"
: (speaker && label && label !== speaker ? label : "");
const rowClass = `${side}${item.partial || item.speakerPending ? " speaker-unknown" : ""}`;
return ` return `
<article class="bubble-row ${rowClass}"> <article class="bubble-row ${side} speaker-${order}">
<div class="bubble-wrapper"> <div class="bubble-wrapper">
<div class="bubble-header"> <div class="bubble-header">
<span class="speaker-badge ${badge}"></span> <span class="speaker-badge ${badge}"></span>
<span class="speaker-name">${escapeHtml(headerMain)}</span> <span class="speaker-name">${escapeHtml(speaker)}</span>
${headerMeta ? `<span class="bubble-time">${escapeHtml(headerMeta)}</span>` : ""} <span class="bubble-time">${escapeHtml(timeRange)}</span>
</div> </div>
<div class="bubble-body ${item.partial ? "interim" : ""}">${escapeHtml(item.text || "")}</div> <div class="bubble-body ${item.partial ? "interim" : ""}">${bodyText}</div>
</div> </div>
</article> </article>
`; `;
}); });
if (pendingItem) {
const identity = (pendingItem.speaker || pendingItem.label || "进行中").trim() || "进行中";
const order = getLiveSpeakerOrder(pendingItem.speakerKey || identity);
const side = order % 2 === 0 ? "speaker-left" : "speaker-right";
rendered.push(`
<article class="bubble-row ${side} speaker-unknown">
<div class="bubble-wrapper">
<div class="bubble-header">
<span class="speaker-badge speaker-color-${order}"></span>
<span class="speaker-name">${escapeHtml(identity)}</span>
</div>
<div class="bubble-body interim">${escapeHtml(pendingItem.text || "")}</div>
</div>
</article>
`);
}
$("liveTranscript").innerHTML = rendered.join(""); $("liveTranscript").innerHTML = rendered.join("");
$("liveTranscript").scrollTop = $("liveTranscript").scrollHeight; $("liveTranscript").scrollTop = $("liveTranscript").scrollHeight;
} }
@ -4204,6 +4206,8 @@ ${transcript}
speakerPending = false, speakerPending = false,
segmentIndex = null, segmentIndex = null,
speakerKey = "", speakerKey = "",
startTimeMs = 0,
endTimeMs = 0,
) { ) {
const safeText = (text || "").trim(); const safeText = (text || "").trim();
if (!safeText) return; if (!safeText) return;
@ -4216,6 +4220,8 @@ ${transcript}
last.speakerPending = speakerPending; last.speakerPending = speakerPending;
last.segmentIndex = segmentIndex; last.segmentIndex = segmentIndex;
last.speakerKey = speakerKey || last.speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`); last.speakerKey = speakerKey || last.speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`);
last.startTimeMs = startTimeMs;
last.endTimeMs = endTimeMs;
renderLiveTranscript(); renderLiveTranscript();
return; return;
} }
@ -4235,6 +4241,8 @@ ${transcript}
existing.speaker = speaker; existing.speaker = speaker;
existing.speakerPending = speakerPending; existing.speakerPending = speakerPending;
existing.speakerKey = speakerKey || existing.speakerKey || `segment-${segmentIndex}`; existing.speakerKey = speakerKey || existing.speakerKey || `segment-${segmentIndex}`;
existing.startTimeMs = startTimeMs;
existing.endTimeMs = endTimeMs;
renderLiveTranscript(); renderLiveTranscript();
return; return;
} }
@ -4248,6 +4256,8 @@ ${transcript}
speakerPending, speakerPending,
segmentIndex, segmentIndex,
speakerKey: speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`), speakerKey: speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`),
startTimeMs,
endTimeMs,
}); });
renderLiveTranscript(); renderLiveTranscript();
} }