fix: 修正音频通路卡死导致语音输入输出长时间运行后失效的问题
用户反馈 pad 长时间运行后语音输入输出都不再工作,拿到的现场诊断显示: pulseaudio 占 49% CPU,系统里积压了 70 个从 7月5日就存在的 `aplay -t raw` 残留进程。 根因:systemTts.ts 每次播报一个分句都会 spawn 一个 aplay 子进程, 全部数据写完后调用 stdin.end(),然后完全依赖 aplay 自己触发 'exit' 事件才清理状态(isSpeaking = false)。这台设备的 PulseAudio 大约空闲 5 秒就会把音频设备挂起(代码里已有相关注释),长时间运行后一旦某次 "唤醒音频通路"没成功,aplay 就会卡在往挂死的通路写数据,stdin.end() 之后永远等不到 'exit'——播放侧完全没有超时兜底(合成侧 vitsWorker 倒是 已经有 60 秒超时)。isSpeaking 永久卡 true,playNext() 再不会处理 队列里的后续句子,整个 TTS 直接哑掉;ASR 走的是渲染进程里 getUserMedia/AudioContext,用的是同一个被拖垮的 PulseAudio 实例, 所以语音输入也跟着失效。更麻烦的是 systemTtsStop() 只发 SIGTERM, 卡在系统调用里的 aplay 收不到效果杀不掉——每次打断播报(每次提问前 都会调用一次)都可能在后台攒下一个杀不死的僵尸进程,日积月累到 70 个, 把 PulseAudio 彻底拖垮。 修复(src/main/tts/systemTts.ts): 1. speakWithVits / speakWithPiperStreaming 的播放阶段新增 PLAYER_EXIT_TIMEOUT_MS(8 秒)看门狗:stdin.end() 后超时未触发 'exit' 就判定音频通路卡死,SIGKILL 强杀并以 done(error) 收尾, 让队列能继续推进而不是永久卡死。 2. systemTtsStop() 新增 killWithEscalation():先 SIGTERM,1.5 秒 宽限期后进程还没退出再 SIGKILL,避免每次打断播报都在后台攒一个 杀不掉的僵尸进程。 验证:真机上先用 pkill 清掉 70 个残留 aplay、重启 pulseaudio (CPU 49% 降到 1.6%),部署修复后: - 正常播报全程无异常,结束后无残留进程 - 用 kill -STOP 冻结 pulseaudio 制造"音频通路卡死"的真实场景, 看门狗在 7.8 秒(吻合 8 秒超时设定)后正确强杀卡住的 aplay, 队列自动继续播放下一句,全程结束后无残留进程 过程中还排查了一次 pad 网络连不上的问题:本机 Clash for Windows 开着 TUN 模式且运行在全局模式(mode: global),局域网流量也会被 接管,与此次 TTS/ASR bug 无关,已切回 rule 模式;pad 本身 IP 没变, 是 ARP 缓存过期,pad 主动 ping 一次后双方缓存刷新即恢复,供以后 排查类似"局域网设备连不上"问题参考。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
This commit is contained in:
parent
e95699b21b
commit
417fbbd9f5
@ -207,6 +207,18 @@ const WAKE_TIMEOUT_MS = WAKE_SILENCE_MS + 3000
|
||||
const PAUSE_LONG_MS = 200
|
||||
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 单声道) */
|
||||
function buildSilenceRaw(durationMs: number, sampleRate: number): Buffer {
|
||||
return Buffer.alloc(Math.floor((sampleRate * durationMs) / 1000) * 2)
|
||||
@ -611,9 +623,17 @@ function speakWithPiperStreaming(
|
||||
const fullText = segments.join('')
|
||||
|
||||
let resolved = false
|
||||
let playerExitTimer: NodeJS.Timeout | null = null
|
||||
const clearPlayerExitTimer = () => {
|
||||
if (playerExitTimer) {
|
||||
clearTimeout(playerExitTimer)
|
||||
playerExitTimer = null
|
||||
}
|
||||
}
|
||||
const done = (error?: string) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearPlayerExitTimer()
|
||||
cleanup()
|
||||
try {
|
||||
rmSync(segDir, { recursive: true, force: true })
|
||||
@ -744,6 +764,19 @@ function speakWithPiperStreaming(
|
||||
} catch {
|
||||
// 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-'))
|
||||
|
||||
let resolved = false
|
||||
let playerExitTimer: NodeJS.Timeout | null = null
|
||||
const clearPlayerExitTimer = () => {
|
||||
if (playerExitTimer) {
|
||||
clearTimeout(playerExitTimer)
|
||||
playerExitTimer = null
|
||||
}
|
||||
}
|
||||
const isAlive = () => myToken === ttsStreamToken && !resolved
|
||||
const done = (error?: string) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearPlayerExitTimer()
|
||||
// 通知 worker 停止后续合成(模型保持加载,worker 常驻不退出)
|
||||
try {
|
||||
vitsWorker?.send({ cmd: 'cancel' })
|
||||
@ -1015,6 +1056,20 @@ function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
|
||||
} catch {
|
||||
// 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)
|
||||
}
|
||||
})()
|
||||
|
||||
@ -1179,6 +1234,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 {
|
||||
// 递增代际标记,进行中的分段喂流循环会据此中止
|
||||
ttsStreamToken++
|
||||
@ -1190,11 +1272,7 @@ export function systemTtsStop(): void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
currentProcess.kill('SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
killWithEscalation(currentProcess)
|
||||
currentProcess = null
|
||||
}
|
||||
if (currentPlayer) {
|
||||
@ -1203,11 +1281,7 @@ export function systemTtsStop(): void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
currentPlayer.kill('SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
killWithEscalation(currentPlayer)
|
||||
currentPlayer = null
|
||||
}
|
||||
queue = []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user