fix: 语音输出恢复"开始播报"、消除加粗文本重复朗读;输入框真正对齐
真机复测又发现三个问题,逐一定位修复: 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 <noreply@anthropic.com>
This commit is contained in:
parent
48a9fb0f6d
commit
8054fcdad1
@ -180,6 +180,7 @@ function beginQuery(): void {
|
|||||||
startWaitTimer()
|
startWaitTimer()
|
||||||
ttsPlayer.stop()
|
ttsPlayer.stop()
|
||||||
syncTtsConfig()
|
syncTtsConfig()
|
||||||
|
if (ttsEnabled.value) ttsPlayer.announceStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendQuery(): Promise<void> {
|
async function sendQuery(): Promise<void> {
|
||||||
@ -865,7 +866,7 @@ onUnmounted(() => {
|
|||||||
display: flex; align-items: flex-end; gap: 14px;
|
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 {
|
.q-input {
|
||||||
width: 100%; box-sizing: border-box;
|
width: 100%; box-sizing: border-box;
|
||||||
|
|||||||
@ -148,7 +148,15 @@ export class TtsPlayer {
|
|||||||
|
|
||||||
this.rawText += text
|
this.rawText += text
|
||||||
const thinkFree = this.stripThinkContent(this.rawText)
|
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)
|
const pending = this.computePendingDelta(plain)
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
|
|
||||||
@ -167,6 +175,15 @@ export class TtsPlayer {
|
|||||||
*/
|
*/
|
||||||
flush(): void {
|
flush(): void {
|
||||||
if (!this.options.enabled) return
|
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()) {
|
if (this.buffer.trim()) {
|
||||||
this.enqueue(this.buffer.trim())
|
this.enqueue(this.buffer.trim())
|
||||||
this.buffer = ''
|
this.buffer = ''
|
||||||
@ -179,6 +196,17 @@ export class TtsPlayer {
|
|||||||
* 分段规则:"。"、";"、"!"、"?" 后长停顿,"、"、":" 后短停顿(约 0.2s~0.3s),
|
* 分段规则:"。"、";"、"!"、"?" 后长停顿,"、"、":" 后短停顿(约 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 {
|
speakWithAnnouncement(rawText: string): void {
|
||||||
if (!this.options.enabled) return
|
if (!this.options.enabled) return
|
||||||
const plain = this.stripThinkAndMarkdown(rawText)
|
const plain = this.stripThinkAndMarkdown(rawText)
|
||||||
@ -331,6 +359,27 @@ export class TtsPlayer {
|
|||||||
* 用“后缀-前缀对齐”计算真正新增的纯文本。
|
* 用“后缀-前缀对齐”计算真正新增的纯文本。
|
||||||
* 当 cleanMarkdown/marked 把 HTML 实体或格式符号压缩时,按内容对齐可避免丢字或重复。
|
* 当 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 {
|
private computePendingDelta(newPlain: string): string {
|
||||||
const prev = this.decodeHtmlEntities(this.lastSpokenPlain)
|
const prev = this.decodeHtmlEntities(this.lastSpokenPlain)
|
||||||
if (!prev) return newPlain
|
if (!prev) return newPlain
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user