Improve realtime speaker display and transcript spacing

This commit is contained in:
Xulilong 2026-07-16 09:50:29 +08:00
parent 4ec8bfb93b
commit 67edea1b4b
5 changed files with 188 additions and 148 deletions

View File

@ -606,7 +606,9 @@ class A2AOrchestrator:
def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
text = (data.get("text") or "").strip()
speaker = (data.get("spk_name") or "").strip()
if not text or not speaker or speaker.lower() == "unknown":
if not speaker or speaker.lower() == "unknown":
speaker = get_env("REALTIME_DEFAULT_SPEAKER", "SPEAKER_00")
if not text or not speaker:
return None
segment = {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1105,30 +1105,30 @@
<div v-if="isDualRealtimeTranscription" ref="dualTranscriptContainer"
class="dual-transcript-stack" style="height: 650px;">
<textarea v-if="isEditingTranscription"
class="w-full p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: dualTranscriptTopPercent + '%' }"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextareaOnline"
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: dualTranscriptTopPercent + '%' }"
:value="fullRealtimeTranscription" readonly></textarea>
<textarea v-if="isEditingTranscription"
class="w-full p-4 text-lg leading-8 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: dualTranscriptTopPercent + '%' }"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextareaOnline"
class="w-full p-4 text-lg leading-8 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: dualTranscriptTopPercent + '%' }"
:value="fullRealtimeTranscription" readonly></textarea>
<div class="dual-transcript-divider" @mousedown="startDualTranscriptResize"></div>
<textarea v-if="isEditingTranscription"
class="w-full p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: (100 - dualTranscriptTopPercent) + '%' }"
v-model="systemEditableText"></textarea>
<textarea v-else ref="systemAsrResultTextareaOnline"
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: (100 - dualTranscriptTopPercent) + '%' }"
:value="fullSystemRealtimeTranscription" readonly></textarea>
</div>
<textarea v-else-if="isEditingTranscription" style="height: 650px;"
class="w-full p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextareaOnline" style="height: 650px;"
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullRealtimeTranscription" readonly></textarea>
<textarea v-if="isEditingTranscription"
class="w-full p-4 text-lg leading-8 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: (100 - dualTranscriptTopPercent) + '%' }"
v-model="systemEditableText"></textarea>
<textarea v-else ref="systemAsrResultTextareaOnline"
class="w-full p-4 text-lg leading-8 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:style="{ height: (100 - dualTranscriptTopPercent) + '%' }"
:value="fullSystemRealtimeTranscription" readonly></textarea>
</div>
<textarea v-else-if="isEditingTranscription" style="height: 650px;"
class="w-full p-4 text-lg leading-8 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextareaOnline" style="height: 650px;"
class="w-full p-4 text-lg leading-8 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullRealtimeTranscription" readonly></textarea>
<!-- <textarea
ref="asrTextarea"
@ -1353,32 +1353,32 @@ ${item.name}
<!-- 弹框主体 -->
<div class="p-4">
<div v-if="isDualRealtimeTranscription" class="dual-transcript-stack" style="height: 90vh;">
<textarea v-if="isEditingTranscription"
:style="{ fontSize: baseInfo.fontSize + 'px', height: dualTranscriptTopPercent + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextarea"
:style="{ fontSize: baseInfo.fontSize + 'px', height: dualTranscriptTopPercent + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullRealtimeTranscription" readonly></textarea>
<textarea v-if="isEditingTranscription"
:style="{ fontSize: baseInfo.fontSize + 'px', lineHeight: 1.45, height: dualTranscriptTopPercent + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-4 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextarea"
:style="{ fontSize: baseInfo.fontSize + 'px', lineHeight: 1.45, height: dualTranscriptTopPercent + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-4 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullRealtimeTranscription" readonly></textarea>
<div class="dual-transcript-divider" @mousedown="startDualTranscriptResize"></div>
<textarea v-if="isEditingTranscription"
:style="{ fontSize: baseInfo.fontSize + 'px', height: (100 - dualTranscriptTopPercent) + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="systemEditableText"></textarea>
<textarea v-else ref="systemAsrResultTextarea"
:style="{ fontSize: baseInfo.fontSize + 'px', height: (100 - dualTranscriptTopPercent) + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullSystemRealtimeTranscription" readonly></textarea>
</div>
<textarea v-else-if="isEditingTranscription"
:style="{ fontSize: baseInfo.fontSize + 'px',height:'90vh',backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full h-60 p-2 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextarea"
:style="{ fontSize: baseInfo.fontSize + 'px',height:'90vh',backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full h-60 p-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullRealtimeTranscription" readonly></textarea>
<textarea v-if="isEditingTranscription"
:style="{ fontSize: baseInfo.fontSize + 'px', lineHeight: 1.45, height: (100 - dualTranscriptTopPercent) + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-4 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="systemEditableText"></textarea>
<textarea v-else ref="systemAsrResultTextarea"
:style="{ fontSize: baseInfo.fontSize + 'px', lineHeight: 1.45, height: (100 - dualTranscriptTopPercent) + '%', backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full p-4 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullSystemRealtimeTranscription" readonly></textarea>
</div>
<textarea v-else-if="isEditingTranscription"
:style="{ fontSize: baseInfo.fontSize + 'px', lineHeight: 1.45, height:'90vh',backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full h-60 p-4 border-2 border-blue-500 rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
v-model="editableText"></textarea>
<textarea v-else ref="asrResultTextarea"
:style="{ fontSize: baseInfo.fontSize + 'px', lineHeight: 1.45, height:'90vh',backgroundColor:'rgba(0, 94, 170, 0.8)' }"
class="w-full h-60 p-4 border border-[var(--border-primary)] rounded-md bg-[var(--bg-secondary)] text-[var(--text-primary)] resize-none overflow-y-auto"
:value="fullRealtimeTranscription" readonly></textarea>
</div>
</div>

View File

@ -848,15 +848,15 @@ export class RealtimeAsrPipeline {
let correctedText = item.correctedText || item.rawText || "";
// 构造 segment 项
const segment = {
text: correctedText,
sourceText: item.sourceText,
timestamp: item.timestamp,
timestampMs: item.timestampMs,
speaker: null,
processed: true,
epoch: item.epoch,
};
const segment = {
text: correctedText,
sourceText: item.sourceText,
timestamp: item.timestamp,
timestampMs: item.timestampMs,
speaker: item.speaker || null,
processed: true,
epoch: item.epoch,
};
// 按 timestampMs 有序插入 segmentList确保最终渲染顺序与音频时间一致
const insertIndex = this.segmentList.findIndex(
@ -1125,7 +1125,7 @@ export class RealtimeAsrPipeline {
let lastRenderedSpeaker = initialSpeaker;
let lastContentChar = "";
segments.forEach((segment) => {
segments.forEach((segment, index) => {
if (this.getShowSpeaker()) {
if (
segment.speaker &&
@ -1145,16 +1145,19 @@ export class RealtimeAsrPipeline {
}
}
const segText = this.handleWithTimestamp(
segment.text,
segment.timestamp,
const segText = this.handleWithTimestamp(
segment.text,
segment.timestamp,
this.getConfigWords(),
6,
lastContentChar
);
displayText += segText;
// 跟踪上一个 segment 的末尾非空白字符,传给下一次 handleWithTimestamp
);
displayText += segText;
if (index < segments.length - 1 && !/\n\s*$/.test(displayText)) {
displayText += "\n\n";
}
// 跟踪上一个 segment 的末尾非空白字符,传给下一次 handleWithTimestamp
const trimmed = segText.replace(/\s+$/, "");
if (trimmed.length > 0) {
lastContentChar = trimmed.slice(-1);
@ -1166,7 +1169,7 @@ export class RealtimeAsrPipeline {
// 将已矫正并提交的 segment 渲染到主转录区。pending/online 预览文本保持独立,
// 主文本更新后再同步预览区。
renderFullText() {
renderFullText() {
const initialSpeaker =
this.segmentRenderStartIndex > 0
? this.getSpeakerBeforeIndex(this.segmentRenderStartIndex)
@ -1181,12 +1184,76 @@ export class RealtimeAsrPipeline {
const mainText = this._deduplicateConsecutivePunctuation(
this.getPausedTranscription() + this.recText
);
this.onMainTextChange(mainText);
this.syncRealtimePreview();
}
// online websocket 返回低延迟片段,只更新预览区;正式 offline 句子统一交给
// handleOfflineMessage避免双通路重复落句。
handleOnlineMessage(event) {
this.onMainTextChange(mainText);
this.syncRealtimePreview();
}
applySpeakerMessage(parsedMessage, messageEpoch) {
const timestamp = parsedMessage.timestamp;
const messageTimestampMs = this.extractTimestampMs(timestamp);
if (this.isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
return;
}
const speaker =
(parsedMessage.segments &&
parsedMessage.segments[0] &&
parsedMessage.segments[0].speaker) ||
parsedMessage.speaker ||
"SPEAKER_00";
let didApply = false;
for (let i = this.pendingSentenceQueue.length - 1; i >= 0; i--) {
const pending = this.pendingSentenceQueue[i];
if (!pending || pending.epoch !== messageEpoch || pending.speaker) {
continue;
}
if (
Number.isFinite(messageTimestampMs) &&
Number.isFinite(pending.timestampMs) &&
pending.timestampMs !== messageTimestampMs
) {
continue;
}
pending.speaker = speaker;
didApply = true;
break;
}
let targetIndex = -1;
for (let i = this.segmentList.length - 1; i >= 0; i--) {
const segment = this.segmentList[i];
if (!segment || segment.epoch !== messageEpoch) {
continue;
}
if (
segment.speaker === null &&
Number.isFinite(messageTimestampMs) &&
Number.isFinite(segment.timestampMs) &&
segment.timestampMs === messageTimestampMs
) {
targetIndex = i;
break;
}
if (targetIndex === -1 && segment.speaker === null) {
targetIndex = i;
}
}
if (targetIndex !== -1) {
this.segmentList[targetIndex].speaker = speaker;
this.renderFullText();
return;
}
if (didApply) {
this.syncRealtimePreview();
}
}
// online websocket 返回低延迟片段,只更新预览区;正式 offline 句子统一交给
// handleOfflineMessage避免双通路重复落句。
handleOnlineMessage(event) {
if (this.disposed) {
return;
}
@ -1238,10 +1305,12 @@ export class RealtimeAsrPipeline {
this._requestCorrection(pendingSentence);
}
}
}
this.syncRealtimePreview();
}
} else if (mode === "offline-speaker") {
this.applySpeakerMessage(parsedMessage, messageEpoch);
}
this.syncRealtimePreview();
}
// offline websocket 才负责正式转录路径2pass-offline 文本进入 LLM 矫正,
// offline-speaker 后续只补说话人信息。
@ -1287,43 +1356,10 @@ export class RealtimeAsrPipeline {
pendingSentence.correctionRequested = true;
this.setPendingCorrectionCount(this.pendingCorrectionCount + 1);
await this._requestCorrection(pendingSentence);
} else if (mode === "offline-speaker") {
// 说话人标签可能晚于矫正文本到达。这里只更新 speaker 元数据,
// 绝不替换 text避免已矫正句子跳回 raw ASR 文本。
if (this.isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) {
return;
}
const speaker =
(parsedMessage.segments &&
parsedMessage.segments[0] &&
parsedMessage.segments[0].speaker) ||
"未命名演讲者";
let targetIndex = -1;
for (let i = this.segmentList.length - 1; i >= 0; i--) {
const segment = this.segmentList[i];
if (!segment || segment.epoch !== messageEpoch) {
continue;
}
if (
segment.speaker === null &&
Number.isFinite(messageTimestampMs) &&
Number.isFinite(segment.timestampMs) &&
segment.timestampMs === messageTimestampMs
) {
targetIndex = i;
break;
}
if (targetIndex === -1 && segment.speaker === null) {
targetIndex = i;
}
}
if (targetIndex !== -1) {
this.segmentList[targetIndex].speaker = speaker;
this.renderFullText();
}
}
}
}
} else if (mode === "offline-speaker") {
// 说话人标签可能早于或晚于矫正文本到达。这里只更新 speaker 元数据,
// 绝不替换 text避免已矫正句子跳回 raw ASR 文本。
this.applySpeakerMessage(parsedMessage, messageEpoch);
}
}
}