Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d2164e1af | ||
|
|
95ee66e836 | ||
|
|
417fbbd9f5 | ||
|
|
e95699b21b | ||
|
|
b45c7d2f27 | ||
|
|
a8bce9f985 | ||
|
|
6a19270514 | ||
|
|
1fdbc7353f | ||
|
|
b7409e5391 |
29
CLAUDE.md
29
CLAUDE.md
@ -203,17 +203,44 @@ const vectors = await Promise.all(texts.map((t, i) => fetchOne(t, i)))
|
|||||||
- **`append()` 里必须是 `this.buffer += pending` 而不是 `=`**。LLM 流式增量很碎(常几个字一个 chunk),用赋值会把上一次还没凑够一句、尚未送去朗读的残留直接冲掉,实际效果是整段正文都丢了、只念出 `flush()` 时残留的一个句号。
|
- **`append()` 里必须是 `this.buffer += pending` 而不是 `=`**。LLM 流式增量很碎(常几个字一个 chunk),用赋值会把上一次还没凑够一句、尚未送去朗读的残留直接冲掉,实际效果是整段正文都丢了、只念出 `flush()` 时残留的一个句号。
|
||||||
- **加粗文本会被念两遍**,根因是 `markdownToPlain()` 每次都从头重新解析整段 `rawText`,而 `**` 闭合前后同一段文字的纯文本结果不一样(未闭合时星号当普通字符,闭合后星号被去掉)。提前把这段还不稳定的文字记为"已朗读",闭合符号一到就会被当成新内容重念。`findStableBoundary()` 因此只处理到最后一个已闭合的 `**` 为止,未闭合的尾巴留到下次;`flush()` 时生成已结束,不再有这顾虑,直接处理完。
|
- **加粗文本会被念两遍**,根因是 `markdownToPlain()` 每次都从头重新解析整段 `rawText`,而 `**` 闭合前后同一段文字的纯文本结果不一样(未闭合时星号当普通字符,闭合后星号被去掉)。提前把这段还不稳定的文字记为"已朗读",闭合符号一到就会被当成新内容重念。`findStableBoundary()` 因此只处理到最后一个已闭合的 `**` 为止,未闭合的尾巴留到下次;`flush()` 时生成已结束,不再有这顾虑,直接处理完。
|
||||||
- **"开始播报"提示音要单独入队**(`announceStart()`),不能碰流式缓冲区状态。
|
- **"开始播报"提示音要单独入队**(`announceStart()`),不能碰流式缓冲区状态。
|
||||||
|
- **生成中途才打开 TTS,会漏掉开关之前已生成的正文**。`append()` 开头是 `if (!this.options.enabled) return`,TTS 关闭期间每次 `onDelta` 调用都被直接挡掉,内部 `rawText`/`lastSpokenPlain` 完全不累积;中途打开后下一次 `onDelta` 只送来新增量,等于从打开那一刻"之后"才开始播。`ChatPanel.vue` 的 `toggleTts()` 因此在 `store.querying` 为真时,除了 `announceStart()` 还要补一次 `ttsPlayer.append(store.rawAnswer)`,把已生成的全文"补喂"一遍,相当于告诉播放器"这就是从头到现在的内容",之后的增量在这个基础上正常追加、不会重复。
|
||||||
|
|
||||||
|
**调试这类"实际发声内容对不对"的问题时的教训**:想在渲染进程 monkey-patch `window.electronAPI.xxx` 来拦截调用做验证是无效的——`contextBridge.exposeInMainWorld` 暴露的对象默认被冻结,赋值会静默失败、不报错,很容易误判成"这条代码路径没走到"。真正能验证的办法是去主进程对应的 `ipcMain.handle(...)` 里临时加 `console.log`(验证完记得删),或者用 CDP 的 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读 DOM/计算样式——这两种都是从"实际发生的效果"验证,不依赖能否篡改渲染进程里的 API 对象。
|
||||||
|
|
||||||
|
### 长时间运行后语音输入输出一起失效(systemTts.ts)
|
||||||
|
|
||||||
|
真机反馈:pad 跑了一两周之后语音输入输出全部失效。现场诊断:pulseaudio 占 49% CPU,系统里积压了 70 个从几天前就存在的 `aplay -t raw` 残留进程。
|
||||||
|
|
||||||
|
**根因跟 `WAKE_IDLE_THRESHOLD_MS` 那条注释里提到的"PulseAudio 大约 5 秒空闲就挂起音频设备"是同一件事**:`speakWithVits`/`speakWithPiperStreaming` 每播一个分句就 spawn 一个 aplay,写完数据后调用 `stdin.end()`,然后完全依赖 aplay 自己触发 `'exit'` 事件才清理 `isSpeaking` 状态——没有任何超时兜底(合成侧 `vitsWorker` 倒是本来就有 60 秒超时)。长时间运行后一旦某次"唤醒音频通路"没成功,aplay 会卡在往挂死的 PulseAudio 写数据,`'exit'` 永远不触发,`isSpeaking` 永久卡 `true`,`playNext()` 不再处理队列,TTS 直接哑掉。**ASR 走的是渲染进程 `getUserMedia`/`AudioContext`,和 TTS 共用同一个被拖垮的 PulseAudio 实例**,所以语音输入也一起失效。更麻烦的是 `systemTtsStop()` 原来只发 `SIGTERM`,卡在系统调用里的 aplay 根本杀不掉——每次打断播报(每次提问前都会调一次)都可能攒下一个杀不死的僵尸进程,日积月累到 70 个,把 PulseAudio 彻底拖垮。
|
||||||
|
|
||||||
|
**修复**:播放阶段加 `PLAYER_EXIT_TIMEOUT_MS`(8 秒)看门狗,`stdin.end()` 后超时未触发 `'exit'` 就判定通路卡死,`SIGKILL` 强杀并 `done(error)` 收尾;`systemTtsStop()` 加 `killWithEscalation()`,先 `SIGTERM`,1.5 秒宽限期后还没退出就 `SIGKILL`。
|
||||||
|
|
||||||
|
**验证方法**:真机上拿 `kill -STOP <pulseaudio_pid>` 故意冻结 PulseAudio,模拟"音频通路卡死"的真实场景,再用 CDP 触发一次播报,确认看门狗在约 8 秒后正确强杀卡住的 aplay、队列自动继续、最终无残留进程,`kill -CONT` 解冻收尾。**这个冻结/解冻手法可以复用**:任何"子进程偶发挂死"的 bug,不必真的等它在真机自然复现,直接 `SIGSTOP` 它依赖的下游服务就能可控地造出卡死场景。
|
||||||
|
|
||||||
### 系统提示词:查不到内容时的答复
|
### 系统提示词:查不到内容时的答复
|
||||||
|
|
||||||
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
|
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
|
||||||
|
|
||||||
**光在提示词里写"每次换一种措辞"是无效的**——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:`generator.ts` 的 `withToneHint()` 每次请求随机挑一条引导追加到系统提示词末尾,让输入本身产生差异。
|
**光在提示词里写"每次换一种措辞"是无效的**——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:`generator.ts` 的 `withToneHint()` 每次请求随机挑一条引导,让输入本身产生差异。
|
||||||
|
|
||||||
**没有采用调高 temperature 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。
|
**没有采用调高 temperature 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。
|
||||||
|
|
||||||
|
**`withToneHint()` 的插入位置踩过一次真实的坑**:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高的位置)。真机复测发现:同一个检索到充分相关内容、理应直接作答的问题,连续追问 3 次,有 1 次被模型硬套上"手册里未查到…"的开场白,即便后面还是照常给出了详细步骤——答案读起来却像没查到,用户也确实这样反馈过。根因是这条指令脱离了它原本所属的"仅在没有足够信息时才用"的条件分句,变成了提示词末尾一条独立显眼的指令,模型会倾向"找机会用上它"。**现在改为把引导语嵌入到原本那条"如果没有足够信息"分句内部**(`NO_MATCH_BULLET_MARKER` 定位插入点,在 `config.ts` 的默认提示词里找 `以便更准确地定位;` 这句),位置和从属语气都跟原本不带随机性的版本保持一致。用户在设置页改写过这句话、不含该标记时,`withToneHint()` 原样返回不插入,不强行拼接导致语义错位。
|
||||||
|
|
||||||
|
**排查这个 bug 时的一个教训**:之前测试这个功能时没能发现问题,是因为测试脚本直接拿纯净的 `systemPrompt` 调用生成接口,没有复现 `withToneHint()` 在真实请求里的行为——以后验证跟 prompt 拼接相关的改动,测试必须经过完整的 `streamGenerate()`/`withToneHint()` 调用链,不能绕开直接测底层生成接口。
|
||||||
|
|
||||||
另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。
|
另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。
|
||||||
|
|
||||||
|
### 触屏 `:hover` 粘滞导致状态色被覆盖(ChatPanel.vue 语音按钮)
|
||||||
|
|
||||||
|
`.voice-btn`(语音输入/语音输出/拍照提问)点击切换"开启"状态后,按钮背景该变成纯 `--green` 填充,真机上却只有边框变绿、背景还是浅灰——看起来像凭空多了一个"焦点框"。
|
||||||
|
|
||||||
|
**排查弯路**:一开始以为是浏览器原生 focus outline,加了 `outline:none` + `:active` 反馈,真机复测毫无变化。
|
||||||
|
|
||||||
|
**真正根因是 CSS 优先级,跟 outline/focus 完全无关**:`.voice-btn.on`(2 个选择器)想把背景铺满 `--green`,但 `.voice-btn:not(:disabled):hover`(3 个选择器)优先级更高。触屏点击后没有鼠标可以"移开",`:hover` 状态会一直粘在刚点过的按钮上不消失(触屏设备通病),于是 hover 规则的浅灰 `background` 把 `.on` 的绿色背景盖掉,只剩下没被 hover 碰到的 `border-color: var(--green)` 还在——视觉上就是一个孤零零的绿框。修复:hover 规则加 `:not(.on)` 排除已开启状态。
|
||||||
|
|
||||||
|
**排查方法论**:真机截图只能看到表象("好像是焦点框"),容易被误导。改用 CDP(Chrome DevTools Protocol)——启动时加 `--remote-debugging-port=9222`,写个不依赖 `ws` 包的最小原生 WebSocket 客户端(`net`+`crypto` 手搓握手帧)连上去,用 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读点击后的 `getComputedStyle()`,直接看到 `background` 实际渲染值是 `--panel-3` 不是 `--green`,才一次性定位到真正被覆盖的是哪条规则。**以后排查"样式明明写了但没生效"类问题,CDP 读计算样式比盯着截图猜更可靠。**
|
||||||
|
|
||||||
### Vue Proxy IPC 序列化
|
### Vue Proxy IPC 序列化
|
||||||
|
|
||||||
Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**:
|
Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**:
|
||||||
|
|||||||
@ -27,22 +27,39 @@ interface ChatMessage {
|
|||||||
|
|
||||||
// 每次请求对模型来说都是无状态的,它并不知道"上一次说了什么",所以只在系统提示词里
|
// 每次请求对模型来说都是无状态的,它并不知道"上一次说了什么",所以只在系统提示词里
|
||||||
// 写"每次换一种措辞"是无效的——实测同样的输入连发三次,答复一字不差。这里改为每次请求
|
// 写"每次换一种措辞"是无效的——实测同样的输入连发三次,答复一字不差。这里改为每次请求
|
||||||
// 随机挑一条措辞引导追加到系统提示词末尾,让输入本身产生差异,答复自然就不会千篇一律。
|
// 随机挑一条措辞引导,让输入本身产生差异,答复自然就不会千篇一律。
|
||||||
//
|
//
|
||||||
// 之所以不用调高 temperature 来制造随机性:那会同时影响命中文档的正常回答,而维修手册
|
// 之所以不用调高 temperature 来制造随机性:那会同时影响命中文档的正常回答,而维修手册
|
||||||
// 问答更看重稳定准确。这些引导只在"查不到内容"时才起作用,正常回答不受影响。
|
// 问答更看重稳定准确。
|
||||||
|
//
|
||||||
|
// 踩过的坑:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高
|
||||||
|
// 的位置),结果它从"仅在查不到内容时才用"的次要条件,变成了一条独立显眼的指令——真机
|
||||||
|
// 复测发现同一个检索到充分相关内容的问题,连续追问 3 次,有 1 次被模型硬套上了"手册里
|
||||||
|
// 未查到…"的开场白,即使后面还是照常给出了详细步骤,答案读起来却像没查到。原因是这条
|
||||||
|
// 指令脱离了它所属的条件分句、又处于 prompt 末尾最显眼的位置,模型会"找机会用上它"。
|
||||||
|
//
|
||||||
|
// 现在改为嵌入到原本那条"如果没有足够信息"分句的内部(MARKER 之后、下一条分句之前),
|
||||||
|
// 并把措辞明确改成"仅当确实需要使用这句话时"——位置和从属语气都跟原本那句不带随机性的
|
||||||
|
// 版本一致,只是具体措辞有随机性。真机复测:同一个问题连跑 5 次全部正常作答,真正查不到
|
||||||
|
// 内容的问题也依然能正确触发(且带上变化后的措辞)。
|
||||||
const NO_MATCH_TONE_HINTS = [
|
const NO_MATCH_TONE_HINTS = [
|
||||||
'若本次需要说明手册中未查到相关内容,请以"很抱歉"起头组织这句话。',
|
'(仅当确实需要使用这句话时,才以"很抱歉"起头组织这句话。)',
|
||||||
'若本次需要说明手册中未查到相关内容,请以"手册里"起头组织这句话。',
|
'(仅当确实需要使用这句话时,才以"手册里"起头组织这句话。)',
|
||||||
'若本次需要说明手册中未查到相关内容,请以"目前检索到的资料"起头组织这句话。',
|
'(仅当确实需要使用这句话时,才以"目前检索到的资料"起头组织这句话。)',
|
||||||
'若本次需要说明手册中未查到相关内容,请以"这部分内容"起头组织这句话。',
|
'(仅当确实需要使用这句话时,才以"这部分内容"起头组织这句话。)',
|
||||||
'若本次需要说明手册中未查到相关内容,请用两句话表达:先说明情况,再给一条补充信息的建议。',
|
'(仅当确实需要使用这句话时,才用两句话表达:先说明情况,再给一条补充信息的建议。)',
|
||||||
'若本次需要说明手册中未查到相关内容,请先简短致歉,再说明可以如何把问题描述得更具体。'
|
'(仅当确实需要使用这句话时,才先简短致歉,再说明可以如何把问题描述得更具体。)'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 必须跟 config.ts 里 DEFAULT_CONFIG.rag.systemPrompt 那条"没有足够信息"分句的结尾保持一致,
|
||||||
|
// 用于定位插入点。用户在设置页改写过这句话、不含这个标记时,原样返回不插入——
|
||||||
|
// 不强行拼接导致语义错位,顶多是少一点措辞变化,不会把提示词弄坏。
|
||||||
|
const NO_MATCH_BULLET_MARKER = '以便更准确地定位;'
|
||||||
|
|
||||||
function withToneHint(systemPrompt: string): string {
|
function withToneHint(systemPrompt: string): string {
|
||||||
|
if (!systemPrompt.includes(NO_MATCH_BULLET_MARKER)) return systemPrompt
|
||||||
const hint = NO_MATCH_TONE_HINTS[Math.floor(Math.random() * NO_MATCH_TONE_HINTS.length)]
|
const hint = NO_MATCH_TONE_HINTS[Math.floor(Math.random() * NO_MATCH_TONE_HINTS.length)]
|
||||||
return `${systemPrompt}\n${hint}`
|
return systemPrompt.replace(NO_MATCH_BULLET_MARKER, `${NO_MATCH_BULLET_MARKER}\n ${hint}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成服务经 er_backend 代理访问其管理的 llama-server(OpenAI 兼容接口,SSE 流式)
|
// 生成服务经 er_backend 代理访问其管理的 llama-server(OpenAI 兼容接口,SSE 流式)
|
||||||
|
|||||||
@ -207,6 +207,18 @@ const WAKE_TIMEOUT_MS = WAKE_SILENCE_MS + 3000
|
|||||||
const PAUSE_LONG_MS = 200
|
const PAUSE_LONG_MS = 200
|
||||||
const PAUSE_SHORT_MS = 100
|
const PAUSE_SHORT_MS = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stdin 写完(EOF)后等待播放器自行退出的最长时间。正常情况下 aplay 把缓冲里剩余的
|
||||||
|
* 音频播完就会退出,通常远小于 1 秒。真机长时间运行后发现过 PulseAudio 音频通路挂起
|
||||||
|
* 唤醒失败的情况(见上面 WAKE_IDLE_THRESHOLD_MS 的注释——这台设备的 PulseAudio 大约
|
||||||
|
* 5 秒空闲就会把音频设备挂起),aplay 卡在给挂死的通路写数据,stdin.end() 之后永远不
|
||||||
|
* 触发 'exit':没有这个兜底的话,isSpeaking 会永久卡在 true,playNext() 再也不会处理
|
||||||
|
* 队列里后续的句子,相当于整个 TTS 直接哑掉;ASR 共用同一个 PulseAudio 实例,通路卡死
|
||||||
|
* 后录音也会跟着失败,这正是"长时间运行后语音输入输出都不行"的根因。超时后强制
|
||||||
|
* SIGKILL 并以 done(error) 收尾,让队列能继续推进而不是永久卡死。
|
||||||
|
*/
|
||||||
|
const PLAYER_EXIT_TIMEOUT_MS = 8000
|
||||||
|
|
||||||
/** 生成指定时长的裸静音 PCM(s16le 单声道) */
|
/** 生成指定时长的裸静音 PCM(s16le 单声道) */
|
||||||
function buildSilenceRaw(durationMs: number, sampleRate: number): Buffer {
|
function buildSilenceRaw(durationMs: number, sampleRate: number): Buffer {
|
||||||
return Buffer.alloc(Math.floor((sampleRate * durationMs) / 1000) * 2)
|
return Buffer.alloc(Math.floor((sampleRate * durationMs) / 1000) * 2)
|
||||||
@ -611,9 +623,17 @@ function speakWithPiperStreaming(
|
|||||||
const fullText = segments.join('')
|
const fullText = segments.join('')
|
||||||
|
|
||||||
let resolved = false
|
let resolved = false
|
||||||
|
let playerExitTimer: NodeJS.Timeout | null = null
|
||||||
|
const clearPlayerExitTimer = () => {
|
||||||
|
if (playerExitTimer) {
|
||||||
|
clearTimeout(playerExitTimer)
|
||||||
|
playerExitTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
const done = (error?: string) => {
|
const done = (error?: string) => {
|
||||||
if (resolved) return
|
if (resolved) return
|
||||||
resolved = true
|
resolved = true
|
||||||
|
clearPlayerExitTimer()
|
||||||
cleanup()
|
cleanup()
|
||||||
try {
|
try {
|
||||||
rmSync(segDir, { recursive: true, force: true })
|
rmSync(segDir, { recursive: true, force: true })
|
||||||
@ -744,6 +764,19 @@ function speakWithPiperStreaming(
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
// 见 PLAYER_EXIT_TIMEOUT_MS 注释:音频通路卡死时 aplay 可能永远不触发 'exit',
|
||||||
|
// 这里兜底强制结束,避免 isSpeaking 卡死导致后续队列全部哑掉。
|
||||||
|
const stuckPlayer = currentPlayer
|
||||||
|
playerExitTimer = setTimeout(() => {
|
||||||
|
if (!isAlive()) return
|
||||||
|
console.warn('[systemTts] 播放器退出超时,判定音频通路卡死,强制结束本轮播报')
|
||||||
|
try {
|
||||||
|
stuckPlayer?.kill('SIGKILL')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
done('player exit timeout')
|
||||||
|
}, PLAYER_EXIT_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@ -911,10 +944,18 @@ function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
|
|||||||
const segDir = mkdtempSync(join(tmpdir(), 'tts-vits-'))
|
const segDir = mkdtempSync(join(tmpdir(), 'tts-vits-'))
|
||||||
|
|
||||||
let resolved = false
|
let resolved = false
|
||||||
|
let playerExitTimer: NodeJS.Timeout | null = null
|
||||||
|
const clearPlayerExitTimer = () => {
|
||||||
|
if (playerExitTimer) {
|
||||||
|
clearTimeout(playerExitTimer)
|
||||||
|
playerExitTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
const isAlive = () => myToken === ttsStreamToken && !resolved
|
const isAlive = () => myToken === ttsStreamToken && !resolved
|
||||||
const done = (error?: string) => {
|
const done = (error?: string) => {
|
||||||
if (resolved) return
|
if (resolved) return
|
||||||
resolved = true
|
resolved = true
|
||||||
|
clearPlayerExitTimer()
|
||||||
// 通知 worker 停止后续合成(模型保持加载,worker 常驻不退出)
|
// 通知 worker 停止后续合成(模型保持加载,worker 常驻不退出)
|
||||||
try {
|
try {
|
||||||
vitsWorker?.send({ cmd: 'cancel' })
|
vitsWorker?.send({ cmd: 'cancel' })
|
||||||
@ -1015,6 +1056,20 @@ function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
// 数据已全部写完,正常情况下 aplay 播完缓冲就会触发上面的 'exit' 处理器。
|
||||||
|
// 若音频通路卡死(见 PLAYER_EXIT_TIMEOUT_MS 注释),'exit' 永远不会来,
|
||||||
|
// 这里兜底强制结束本轮播报,避免 isSpeaking 卡死导致后续队列全部哑掉。
|
||||||
|
const stuckPlayer = currentPlayer
|
||||||
|
playerExitTimer = setTimeout(() => {
|
||||||
|
if (!isAlive()) return
|
||||||
|
console.warn('[systemTts] 播放器退出超时,判定音频通路卡死,强制结束本轮播报')
|
||||||
|
try {
|
||||||
|
stuckPlayer?.kill('SIGKILL')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
done('player exit timeout')
|
||||||
|
}, PLAYER_EXIT_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@ -1135,7 +1190,23 @@ function speakWithPiper(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每轮播报结束时统一调用。之前只是把 currentProcess/currentPlayer 引用置空,
|
||||||
|
* 没有保证对应的系统进程真的退出——真机复测时发现合成卡死分支(见上面
|
||||||
|
* "synth timeout" 处)只杀了 vitsWorker,没杀已经 spawn 出来、stdin 还开着
|
||||||
|
* 干等数据的 aplay,这个进程会永远卡在那等一个再也不会来的 EOF,形成和
|
||||||
|
* PLAYER_EXIT_TIMEOUT_MS 要解决的问题同一性质的泄漏,只是触发路径不同。
|
||||||
|
* 与其在每个 done() 调用点各自记得去杀 currentPlayer,不如在 cleanup()
|
||||||
|
* 这个唯一的收尾出口统一兜底:无论从哪条路径走到这里,都先尝试杀掉还活着
|
||||||
|
* 的进程,再清空引用。
|
||||||
|
*/
|
||||||
function cleanup(): void {
|
function cleanup(): void {
|
||||||
|
if (currentProcess) {
|
||||||
|
killWithEscalation(currentProcess)
|
||||||
|
}
|
||||||
|
if (currentPlayer) {
|
||||||
|
killWithEscalation(currentPlayer)
|
||||||
|
}
|
||||||
currentProcess = null
|
currentProcess = null
|
||||||
currentPlayer = null
|
currentPlayer = null
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
@ -1179,6 +1250,33 @@ export async function systemTtsSpeak(text: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 先 SIGTERM,一段宽限期后如果进程还没退出再 SIGKILL。
|
||||||
|
* 音频通路卡死时 aplay 可能卡在不可中断的系统调用里,单发 SIGTERM 不一定能把它
|
||||||
|
* 真正杀掉——这里只是把 JS 里的引用清空了、不代表对应的系统进程真的退出了,不加
|
||||||
|
* 兜底的话每次中途打断播报都可能在后台攒下一个杀不掉的僵尸 aplay,长时间运行下来
|
||||||
|
* 越积越多,最终把 PulseAudio 拖垮(见 PLAYER_EXIT_TIMEOUT_MS 注释)。
|
||||||
|
*/
|
||||||
|
function killWithEscalation(proc: ChildProcess, graceMs = 1500): void {
|
||||||
|
let exited = false
|
||||||
|
proc.once('exit', () => {
|
||||||
|
exited = true
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
proc.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (exited) return
|
||||||
|
try {
|
||||||
|
proc.kill('SIGKILL')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, graceMs)
|
||||||
|
}
|
||||||
|
|
||||||
export function systemTtsStop(): void {
|
export function systemTtsStop(): void {
|
||||||
// 递增代际标记,进行中的分段喂流循环会据此中止
|
// 递增代际标记,进行中的分段喂流循环会据此中止
|
||||||
ttsStreamToken++
|
ttsStreamToken++
|
||||||
@ -1190,11 +1288,7 @@ export function systemTtsStop(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
try {
|
killWithEscalation(currentProcess)
|
||||||
currentProcess.kill('SIGTERM')
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
currentProcess = null
|
currentProcess = null
|
||||||
}
|
}
|
||||||
if (currentPlayer) {
|
if (currentPlayer) {
|
||||||
@ -1203,11 +1297,7 @@ export function systemTtsStop(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
try {
|
killWithEscalation(currentPlayer)
|
||||||
currentPlayer.kill('SIGTERM')
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
currentPlayer = null
|
currentPlayer = null
|
||||||
}
|
}
|
||||||
queue = []
|
queue = []
|
||||||
|
|||||||
@ -503,6 +503,18 @@ async function toggleTts(): Promise<void> {
|
|||||||
} else if (store.rawAnswer && !store.querying) {
|
} else if (store.rawAnswer && !store.querying) {
|
||||||
// 答案已显示完毕时打开 TTS,立即从头播
|
// 答案已显示完毕时打开 TTS,立即从头播
|
||||||
ttsPlayer.speakWithAnnouncement(store.rawAnswer)
|
ttsPlayer.speakWithAnnouncement(store.rawAnswer)
|
||||||
|
} else if (store.querying) {
|
||||||
|
// 提问还在流式生成中途打开 TTS:ttsPlayer 内部的 rawText 此前因为
|
||||||
|
// enabled=false 一直没有累积(append() 一开头就 return),直接等后续
|
||||||
|
// onDelta 的话只会从"此刻之后的新增量"开始朗读,把已经显示在屏幕上但
|
||||||
|
// 开 TTS 之前生成的那部分正文漏掉。这里先播"开始播报"提示音(跟正常
|
||||||
|
// 提问开始时一致),再用 append() 把已生成的全文"补喂"一遍,相当于
|
||||||
|
// 告诉播放器"这就是从头到现在的内容",让它从头开始排队朗读;之后
|
||||||
|
// onDelta 送来的增量会在此基础上正常追加,衔接不会重复。此刻还没有
|
||||||
|
// 任何输出(刚发问、第一个 delta 还没到)时 append 传空字符串会被
|
||||||
|
// append() 内部的 if (!text) return 挡掉,不会误触发。
|
||||||
|
ttsPlayer.announceStart()
|
||||||
|
ttsPlayer.append(store.rawAnswer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -960,8 +972,19 @@ onUnmounted(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: all .15s;
|
transition: all .15s;
|
||||||
|
/* 这三个按钮之前没有 :active 态,点下去没有任何即时反馈。加上即时反馈的同时顺手
|
||||||
|
关掉原生 outline(这是纯触屏界面,不依赖 Tab 键盘导航,不需要保留原生焦点框)。*/
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
.voice-btn:not(:disabled):hover { background: var(--panel-3); }
|
/* 真正的"绿框"根因不在 outline/focus,而是 CSS 优先级:.voice-btn:not(:disabled):hover
|
||||||
|
(3 个选择器)比 .voice-btn.on(2 个选择器)优先级更高。触屏点击后没有鼠标可以"移开",
|
||||||
|
:hover 状态会一直粘在刚点过的按钮上不消失(触屏设备的通病),于是 hover 规则的浅灰
|
||||||
|
background 把 .on 本该铺满的纯绿色背景盖掉了,只剩下没被 hover 碰到的绿色边框还在——
|
||||||
|
看起来就像按钮周围凭空多出一个绿框。真机用 CDP 直接读取点击后的计算样式实锤:
|
||||||
|
className 带 "on",但 background 实际渲染出的是 --panel-3 的浅灰色,不是 --green。
|
||||||
|
加 :not(.on) 让 hover 规则在"已开启"状态下让位,背景就能正常显示成绿色。 */
|
||||||
|
.voice-btn:not(:disabled):not(.on):hover { background: var(--panel-3); }
|
||||||
|
.voice-btn:not(:disabled):active { filter: brightness(.92); }
|
||||||
.voice-btn.on {
|
.voice-btn.on {
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
border-color: var(--green);
|
border-color: var(--green);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user