From 8054fcdad1882ddc4dd84404b872d136fc76b01a Mon Sep 17 00:00:00 2001 From: ssurui Date: Thu, 3 Sep 2026 15:54:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AF=AD=E9=9F=B3=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=81=A2=E5=A4=8D"=E5=BC=80=E5=A7=8B=E6=92=AD=E6=8A=A5"?= =?UTF-8?q?=E3=80=81=E6=B6=88=E9=99=A4=E5=8A=A0=E7=B2=97=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=9C=97=E8=AF=BB=EF=BC=9B=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E7=9C=9F=E6=AD=A3=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机复测又发现三个问题,逐一定位修复: 1. 流式播报接上后"开始播报"提示语丢了。TtsPlayer 新增 announceStart(), 只入队播放不碰流式缓冲区状态,beginQuery() 里 stop()+配置同步之后 调用。真机验证:连续问答里从第2轮开始稳定播报"开始播报"(第1轮偶发 缺失,是启动时批量测试自动开始跟语音配置同步的时序竞争,非本次改动 引入的逻辑问题)。 2. 加粗文本(**xxx**)被朗读两遍。根因:markdownToPlain 每次都从头 重新解析整段 rawText,** 闭合符号到达前后同一段文字的纯文本渲染 结果不一样(未闭合时星号当普通字符、闭合后星号被去掉),提前把这段 不稳定文字提交为 lastSpokenPlain 会在闭合符号到达后被重新判定成 "待朗读"造成重复。新增 findStableBoundary(),只处理到最后一个已闭合 的 ** 为止,未闭合的尾部留到下次 append 再处理;flush() 收尾时因为 生成已结束,不再有这个顾虑,直接处理全部剩余内容。真机验证:连续 3 轮问答,每轮都有加粗小标题,全部按顺序推进朗读新内容,无重复。 3. 输入框和按钮行的对不齐问题:之前一直在改 .q-input(文本框本身)的 高度,但真实 DOM 测量显示文本框本身一直是精确的 96px,真正超高的 是它的父容器 .q-input-wrap(渲染成 102.4px,比 96px 多 6.4px), 文本框贴容器顶部导致整体被顶高。给 .q-input-wrap 也显式定死 height:96px。真机像素测量确认对齐。 Co-Authored-By: Claude Sonnet 5 --- src/renderer/src/components/ChatPanel.vue | 3 +- src/renderer/src/utils/tts.ts | 51 ++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/ChatPanel.vue b/src/renderer/src/components/ChatPanel.vue index 832a0c7..25ab552 100644 --- a/src/renderer/src/components/ChatPanel.vue +++ b/src/renderer/src/components/ChatPanel.vue @@ -180,6 +180,7 @@ function beginQuery(): void { startWaitTimer() ttsPlayer.stop() syncTtsConfig() + if (ttsEnabled.value) ttsPlayer.announceStart() } async function sendQuery(): Promise { @@ -865,7 +866,7 @@ onUnmounted(() => { display: flex; align-items: flex-end; gap: 14px; } -.q-input-wrap { flex: 1; min-width: 0; position: relative; } +.q-input-wrap { flex: 1; min-width: 0; height: 96px; position: relative; } .q-input { width: 100%; box-sizing: border-box; diff --git a/src/renderer/src/utils/tts.ts b/src/renderer/src/utils/tts.ts index 5f29ce7..4ad252d 100644 --- a/src/renderer/src/utils/tts.ts +++ b/src/renderer/src/utils/tts.ts @@ -148,7 +148,15 @@ export class TtsPlayer { this.rawText += text const thinkFree = this.stripThinkContent(this.rawText) - const plain = this.markdownToPlain(thinkFree) + // 只处理到"安全边界"为止:排除末尾可能还没闭合的 ** 加粗标记。 + // markdownToPlain 每次都是从头重新解析整段 rawText 的,闭合符号到达前后, + // 同一段文字的纯文本渲染结果会不一样(比如未闭合时星号被当普通字符处理, + // 闭合后星号被去掉、文字保留)。如果提前把这段还不稳定的文字提交为 + // lastSpokenPlain,闭合符号一到,plain 文本变化,会被 computePendingDelta + // 重新判定成"待朗读",导致这段文字被朗读两遍。 + const stableBoundary = this.findStableBoundary(thinkFree) + const stableRaw = thinkFree.slice(0, stableBoundary) + const plain = this.markdownToPlain(stableRaw) const pending = this.computePendingDelta(plain) if (!pending) return @@ -167,6 +175,15 @@ export class TtsPlayer { */ flush(): void { if (!this.options.enabled) return + // 收尾:答案已经生成完毕,不用再担心格式标记后续会变化, + // 把 append() 因 findStableBoundary 搁置的尾部(如收尾正好停在未闭合的 ** 上)也一并处理掉 + const thinkFree = this.stripThinkContent(this.rawText) + const plain = this.markdownToPlain(thinkFree) + const pending = this.computePendingDelta(plain) + if (pending) { + this.lastSpokenPlain = plain + this.buffer += pending + } if (this.buffer.trim()) { this.enqueue(this.buffer.trim()) this.buffer = '' @@ -179,6 +196,17 @@ export class TtsPlayer { * 分段规则:"。"、";"、"!"、"?" 后长停顿,"、"、":" 后短停顿(约 0.2s~0.3s), * 每段一行发给主进程,由主进程统一合成并在段间插入对应时长的静音。 */ + /** + * 播报"开始播报"提示音,用在流式问答开始时(配合 append()/flush())。 + * 只入队播放,不碰 rawText/buffer 等流式缓冲区状态,调用前应已经 + * 通过 stop() 清空过一轮的状态。 + */ + announceStart(): void { + if (!this.options.enabled) return + this.introSpoken = true + this.enqueue('开始播报') + } + speakWithAnnouncement(rawText: string): void { if (!this.options.enabled) return const plain = this.stripThinkAndMarkdown(rawText) @@ -331,6 +359,27 @@ export class TtsPlayer { * 用“后缀-前缀对齐”计算真正新增的纯文本。 * 当 cleanMarkdown/marked 把 HTML 实体或格式符号压缩时,按内容对齐可避免丢字或重复。 */ + /** + * 找到文本里可以安全处理的边界,排除末尾可能还没闭合的 ** 加粗标记 + * (出现次数为奇数,说明最后一个 ** 还没等到它的另一半)。 + * 只处理 ** 是因为这是目前唯一被实测复现"闭合前后纯文本渲染不一致"问题的标记; + * 其它行内标记(*斜体*、`代码`、~~删除线~~)出现频率低很多,先不处理。 + */ + private findStableBoundary(text: string): number { + let count = 0 + let lastUnclosedIndex = -1 + let from = 0 + while (true) { + const found = text.indexOf('**', from) + if (found === -1) break + count++ + lastUnclosedIndex = found + from = found + 2 + } + if (count % 2 === 1) return lastUnclosedIndex + return text.length + } + private computePendingDelta(newPlain: string): string { const prev = this.decodeHtmlEntities(this.lastSpokenPlain) if (!prev) return newPlain