Compare commits
No commits in common. "main" and "v9.0.1" have entirely different histories.
10
CLAUDE.md
10
CLAUDE.md
@ -207,16 +207,6 @@ const vectors = await Promise.all(texts.map((t, i) => fetchOne(t, i)))
|
|||||||
|
|
||||||
**调试这类"实际发声内容对不对"的问题时的教训**:想在渲染进程 monkey-patch `window.electronAPI.xxx` 来拦截调用做验证是无效的——`contextBridge.exposeInMainWorld` 暴露的对象默认被冻结,赋值会静默失败、不报错,很容易误判成"这条代码路径没走到"。真正能验证的办法是去主进程对应的 `ipcMain.handle(...)` 里临时加 `console.log`(验证完记得删),或者用 CDP 的 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读 DOM/计算样式——这两种都是从"实际发生的效果"验证,不依赖能否篡改渲染进程里的 API 对象。
|
**调试这类"实际发声内容对不对"的问题时的教训**:想在渲染进程 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` 它依赖的下游服务就能可控地造出卡死场景。
|
|
||||||
|
|
||||||
### 系统提示词:查不到内容时的答复
|
### 系统提示词:查不到内容时的答复
|
||||||
|
|
||||||
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
|
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
|
||||||
|
|||||||
@ -207,18 +207,6 @@ 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)
|
||||||
@ -623,17 +611,9 @@ 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 })
|
||||||
@ -764,19 +744,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@ -944,18 +911,10 @@ 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' })
|
||||||
@ -1056,20 +1015,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@ -1190,23 +1135,7 @@ 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
|
||||||
@ -1250,33 +1179,6 @@ 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++
|
||||||
@ -1288,7 +1190,11 @@ export function systemTtsStop(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
killWithEscalation(currentProcess)
|
try {
|
||||||
|
currentProcess.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
currentProcess = null
|
currentProcess = null
|
||||||
}
|
}
|
||||||
if (currentPlayer) {
|
if (currentPlayer) {
|
||||||
@ -1297,7 +1203,11 @@ export function systemTtsStop(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
killWithEscalation(currentPlayer)
|
try {
|
||||||
|
currentPlayer.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
currentPlayer = null
|
currentPlayer = null
|
||||||
}
|
}
|
||||||
queue = []
|
queue = []
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user