调整测试界面气泡渲染逻辑
parent
c07a51a010
commit
b9fa3e4d5c
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue