diff --git a/src/main/tts/systemTts.ts b/src/main/tts/systemTts.ts index 87d4916..98e44c6 100644 --- a/src/main/tts/systemTts.ts +++ b/src/main/tts/systemTts.ts @@ -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 { 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 { } 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 { }) } +/** + * 先 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 = []