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

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,
) -> Dict[str, Any]:
speaker_id = self._tencent_speaker_id(payload.get("speaker_id"))
if payload.get("speaker_pending"):
speaker_id = -1
# Keep a provisional cluster ID visible while later segments refine it.
# A genuinely unresolved segment already carries speaker_id=-1.
sentence = {
"sentence_id": int(payload.get("index", 0)),
"sentence_type": int(sentence_type),
@ -2435,10 +2435,10 @@ class Qwen3ASRService:
stable = self._is_stable_speaker_info(speaker_info)
speaker_info["speaker_pending"] = not stable
cluster_index = speaker_info.get("_cluster_index")
has_candidate_speaker = speaker_info.get("speaker_id") not in (None, "", -1, "-1")
if (
stable
and
not speaker_info.get("registry_speaker_id")
has_candidate_speaker
and not speaker_info.get("registry_speaker_id")
and not speaker_info.get("user_id")
):
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"])
if not stable:
speaker_info["speaker_id"] = -1
speaker_info["speaker_name"] = ""
return speaker_info
async def _resolve_and_emit_segment_speaker(
@ -2505,8 +2501,6 @@ class Qwen3ASRService:
segment_end_ms=segment_end_ms,
speaker_info=speaker_info,
)
if before_view == after_view:
return
queue_backlog = ctx.speaker_job_queue.qsize()
updates: List[Dict[str, Any]] = []
if queue_backlog <= 1:
@ -2520,7 +2514,10 @@ class Qwen3ASRService:
int(updated["segment_index"]),
)
return
await self._emit_speaker_update(websocket, ctx, task_id, segment_index)
# 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)
except Exception as 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;
}
/* 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 {
max-width: min(78%, 760px);
}
@ -3920,18 +3938,13 @@ ${transcript}
function formatLiveSpeaker(meta) {
if (!meta) return "";
const rawName = meta.speaker_name ?? meta.speaker_id ?? "";
const numericSpeakerId = Number(meta.speaker_id);
if (Number.isFinite(numericSpeakerId) && numericSpeakerId < 0 && !meta.speaker_name) {
return "";
const name = String(meta.speaker_name || "").trim();
if (name) return name;
if (Number.isFinite(numericSpeakerId) && numericSpeakerId >= 0) {
return `说话人 ${numericSpeakerId}`;
}
const name = String(rawName).trim();
if (!name) return "";
const confidence = Number(meta.speaker_confidence);
if (Number.isFinite(confidence) && confidence > 0) {
return `${name} (${confidence.toFixed(2)})`;
}
return name;
return typeof meta.speaker_id === "string" ? meta.speaker_id.trim() : "";
}
function buildLiveSpeakerKey(meta, fallbackSegmentIndex = null) {
@ -3940,7 +3953,6 @@ ${transcript}
}
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.speaker_name) return `name:${String(meta.speaker_name).trim()}`;
const numericSpeakerId = Number(meta.speaker_id);
if (Number.isFinite(numericSpeakerId) && numericSpeakerId >= 0) {
return `speaker:${numericSpeakerId}`;
@ -3948,9 +3960,20 @@ ${transcript}
if (typeof meta.speaker_id === "string" && 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}`;
}
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) {
const safeKey = String(key ?? "unknown").trim() || "unknown";
if (!(safeKey in state.liveSpeakerOrderMap)) {
@ -3980,14 +4003,17 @@ ${transcript}
const isPartial = Number(sentence?.sentence_type ?? 0) === 0;
const speakerLabel = formatLiveSpeaker(sentence);
const speakerKey = buildLiveSpeakerKey(sentence, segmentIndex);
const speakerPending = Number(sentence?.speaker_id) < 0 && !speakerLabel;
addLiveEntry(
`${isPartial ? "进行中" : "已确认"} Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`,
(sentence?.sentence || "").trim(),
isPartial,
speakerLabel,
Number(sentence?.speaker_id) < 0,
speakerPending,
segmentIndex,
speakerKey,
Number(sentence?.start_time ?? 0),
Number(sentence?.end_time ?? 0),
);
});
const confirmedCount = state.liveTranscript.filter((item) => !item.partial).length;
@ -4010,6 +4036,8 @@ ${transcript}
text: sentence?.sentence || "",
speaker_id: sentence?.speaker_id,
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,
@ -4036,14 +4064,17 @@ ${transcript}
const segmentIndex = Number(segment?.index ?? idx);
const speakerLabel = formatLiveSpeaker(segment);
const speakerKey = buildLiveSpeakerKey(segment, segmentIndex);
const speakerPending = Number(segment?.speaker_id) < 0 && !speakerLabel;
return {
label: `已确认 Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`,
text: (segment?.text || "").trim(),
partial: false,
speaker: speakerLabel,
speakerPending: Number(segment?.speaker_id) < 0,
speakerPending,
segmentIndex,
speakerKey,
startTimeMs: Number(segment?.start_time ?? 0),
endTimeMs: Number(segment?.end_time ?? 0),
};
}).filter((item) => item.text);
@ -4070,72 +4101,43 @@ ${transcript}
$("liveTranscript").innerHTML = `<div class="empty-state">点击“开始实时会议”后,这里会像 demo 一样滚动显示气泡和实时变化。</div>`;
return;
}
const lastItem = items[items.length - 1];
const pendingItem = lastItem?.partial ? lastItem : null;
const baseItems = pendingItem ? items.slice(0, -1) : items;
if (!baseItems.length && pendingItem) {
const identity = (pendingItem.speaker || pendingItem.label || "进行中").trim() || "进行中";
const order = getLiveSpeakerOrder(pendingItem.speakerKey || identity);
const side = order % 2 === 0 ? "speaker-left" : "speaker-right";
$("liveTranscript").innerHTML = `
<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").scrollTop = $("liveTranscript").scrollHeight;
return;
}
const rendered = baseItems.map((item) => {
const rendered = items.map((item) => {
const speaker = (item.speaker || "").trim();
const label = (item.label || "").trim();
const identity = item.speakerPending ? "未确认说话人" : (speaker || label || "系统");
const order = getLiveSpeakerOrder(item.speakerKey || identity);
const speakerPending = item.speakerPending || !speaker;
const timeRange = formatLiveTimeRange(item.startTimeMs, item.endTimeMs);
const bodyText = `${escapeHtml(item.text || "")}${item.partial ? " ..." : ""}`;
if (speakerPending) {
return `
<article class="bubble-row speaker-left speaker-unknown">
<div class="bubble-wrapper">
<div class="bubble-header">
<span class="speaker-badge speaker-color-unknown"></span>
<span class="speaker-name">说话人不确定</span>
<span class="bubble-time">${escapeHtml(timeRange)}</span>
</div>
<div class="bubble-body ${item.partial ? "interim" : ""}">${bodyText}</div>
</div>
</article>
`;
}
const order = getLiveSpeakerOrder(item.speakerKey || speaker);
const side = order % 2 === 0 ? "speaker-left" : "speaker-right";
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 `
<article class="bubble-row ${rowClass}">
<article class="bubble-row ${side} speaker-${order}">
<div class="bubble-wrapper">
<div class="bubble-header">
<span class="speaker-badge ${badge}"></span>
<span class="speaker-name">${escapeHtml(headerMain)}</span>
${headerMeta ? `<span class="bubble-time">${escapeHtml(headerMeta)}</span>` : ""}
<span class="speaker-name">${escapeHtml(speaker)}</span>
<span class="bubble-time">${escapeHtml(timeRange)}</span>
</div>
<div class="bubble-body ${item.partial ? "interim" : ""}">${escapeHtml(item.text || "")}</div>
<div class="bubble-body ${item.partial ? "interim" : ""}">${bodyText}</div>
</div>
</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").scrollTop = $("liveTranscript").scrollHeight;
}
@ -4204,6 +4206,8 @@ ${transcript}
speakerPending = false,
segmentIndex = null,
speakerKey = "",
startTimeMs = 0,
endTimeMs = 0,
) {
const safeText = (text || "").trim();
if (!safeText) return;
@ -4216,6 +4220,8 @@ ${transcript}
last.speakerPending = speakerPending;
last.segmentIndex = segmentIndex;
last.speakerKey = speakerKey || last.speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`);
last.startTimeMs = startTimeMs;
last.endTimeMs = endTimeMs;
renderLiveTranscript();
return;
}
@ -4235,6 +4241,8 @@ ${transcript}
existing.speaker = speaker;
existing.speakerPending = speakerPending;
existing.speakerKey = speakerKey || existing.speakerKey || `segment-${segmentIndex}`;
existing.startTimeMs = startTimeMs;
existing.endTimeMs = endTimeMs;
renderLiveTranscript();
return;
}
@ -4248,6 +4256,8 @@ ${transcript}
speakerPending,
segmentIndex,
speakerKey: speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`),
startTimeMs,
endTimeMs,
});
renderLiveTranscript();
}