排查确认两处真实浪费: 1. resources/tts/piper/ 里除 espeak-ng-data 外的所有文件(Piper 神经 语音模型、二进制、自带 onnxruntime、phonemize 库、tashkeel 阿拉伯语 音标模型,合计 ~113M)从未被实际调用——detectBackend() 第一优先级 永远命中 VITS(小雅),Piper 引擎分支代码走不到,只有它 bundle 里的 espeak-ng-data 目录被 VITS 借用当 sherpa-onnx 校验的 dataDir。 systemTts.ts 新增 findPiperEspeakDataDir(),VITS 分支改为只依赖这一 个子目录,不再要求 Piper 引擎的 bin/model/config 存在。 2. electron-builder.config.ts 的 files 补充排除 onnxruntime-web( @huggingface/transformers 的硬依赖但项目强制走 onnxruntime-node, 完全未用)、naive-ui/date-fns/highlight.js/lodash 系(只在 src/renderer/ 被引用,electron-vite build 时已被 Vite 打包进 out/renderer/*.js,主进程不需要原始 node_modules),约省 227M。 两项合计预计单包体积从 ~789M 降到 ~560M 左右,待真机重新打包安装、 实际听一次语音播报确认功能未受影响。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1223 lines
39 KiB
TypeScript
1223 lines
39 KiB
TypeScript
import { spawn, execFile, fork, type ChildProcess, type SpawnOptions } from 'child_process'
|
||
import { EventEmitter } from 'events'
|
||
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, mkdtempSync, rmSync } from 'fs'
|
||
import { join, dirname } from 'path'
|
||
import { tmpdir } from 'os'
|
||
import { which } from '../utils/which'
|
||
|
||
export interface SystemTtsOptions {
|
||
rate?: number
|
||
volume?: number
|
||
pitch?: number
|
||
voiceURI?: string
|
||
}
|
||
|
||
interface QueueItem {
|
||
text: string
|
||
resolve: () => void
|
||
}
|
||
|
||
interface TtsEventPayload {
|
||
text: string
|
||
error?: string
|
||
}
|
||
|
||
type Backend = 'bundled-vits' | 'bundled-piper' | 'bundled-espeak-ng' | 'spd-say' | 'espeak-ng' | 'espeak'
|
||
type AudioPlayer = 'aplay' | 'paplay' | 'ffplay'
|
||
|
||
const emitter = new EventEmitter()
|
||
|
||
let currentProcess: ChildProcess | null = null
|
||
let currentPlayer: ChildProcess | null = null
|
||
let queue: QueueItem[] = []
|
||
let isSpeaking = false
|
||
let detectedBackend: Backend | null = null
|
||
let detectedBackendPromise: Promise<Backend | null> | null = null
|
||
let detectedAudioPlayer: AudioPlayer | null = null
|
||
let currentOptions: SystemTtsOptions = {}
|
||
|
||
// 上次播放结束时间。PulseAudio 的 module-suspend-on-idle 会在空闲约 5 秒后挂起声卡,
|
||
// 挂起后恢复播放时 HDMI 通路需要约 1 秒才真正出声,期间写入的音频会被丢弃(表现为吞掉开头)。
|
||
// 空闲超过阈值后先播一段静音唤醒音频通路,再播放真实语音。
|
||
let lastPlaybackEndAt = 0
|
||
|
||
// 内嵌 sherpa-onnx VITS(小雅,BZNSYP 标准普通话数据集)路径
|
||
let bundledVitsDir: string | null = null
|
||
// TTS worker 子进程(独立进程加载 sherpa-onnx-node,避免与 onnxruntime-node DLL 冲突)
|
||
let vitsWorker: ChildProcess | null = null
|
||
let vitsWorkerReady = false
|
||
let vitsSampleRate = 22050
|
||
let vitsWorkerInitPromise: Promise<void> | null = null
|
||
// 当前播报的分段到达/错误回调(由 speakWithVits 注册)
|
||
let vitsSegmentHandler: ((index: number) => void) | null = null
|
||
let vitsErrorHandler: ((error: string) => void) | null = null
|
||
|
||
// 内嵌 Piper 路径
|
||
let bundledPiperBin: string | null = null
|
||
let bundledPiperDir: string | null = null
|
||
let bundledPiperModel: string | null = null
|
||
let bundledPiperConfig: string | null = null
|
||
// Piper 模型采样率(从模型 config 读取,用于裸流播放)
|
||
let bundledPiperSampleRate = 22050
|
||
// 流式播放代际标记:stop() 时递增,进行中的分段喂流循环据此中止
|
||
let ttsStreamToken = 0
|
||
|
||
// 内嵌 espeak-ng 路径
|
||
let bundledEspeakBin: string | null = null
|
||
let bundledEspeakDataDir: string | null = null
|
||
let bundledEspeakLibDir: string | null = null
|
||
|
||
export function onSystemTtsEvent(
|
||
callback: (event: 'started' | 'done' | 'error', payload: TtsEventPayload) => void
|
||
): void {
|
||
emitter.on('event', callback)
|
||
}
|
||
|
||
function notify(event: 'started' | 'done' | 'error', payload: TtsEventPayload): void {
|
||
emitter.emit('event', event, payload)
|
||
}
|
||
|
||
function findBundledPiper(): {
|
||
bin: string
|
||
dir: string
|
||
model: string
|
||
config: string
|
||
} | null {
|
||
const candidates = [
|
||
join(process.resourcesPath, 'tts', 'piper'),
|
||
join(__dirname, '..', '..', 'resources', 'tts', 'piper'),
|
||
process.env.TTS_BUNDLE_DIR ? join(process.env.TTS_BUNDLE_DIR, 'piper') : ''
|
||
].filter(Boolean)
|
||
|
||
for (const dir of candidates) {
|
||
const bin = join(dir, 'piper')
|
||
const model = join(dir, 'models', 'zh_CN-huayan-medium.onnx')
|
||
const config = join(dir, 'models', 'zh_CN-huayan-medium.onnx.json')
|
||
if (existsSync(bin) && existsSync(model) && existsSync(config)) {
|
||
return { bin, dir, model, config }
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 查找内嵌的 sherpa-onnx VITS 中文模型(小雅,标准普通话)。
|
||
* 相比 Piper 自带的 huayan(带台湾腔),该模型基于 BZNSYP 标准普通话数据集。
|
||
*/
|
||
function findBundledVits(): string | null {
|
||
const candidates = [
|
||
join(process.resourcesPath, 'tts', 'vits-xiao_ya'),
|
||
join(__dirname, '..', '..', 'resources', 'tts', 'vits-xiao_ya'),
|
||
process.env.TTS_BUNDLE_DIR ? join(process.env.TTS_BUNDLE_DIR, 'vits-xiao_ya') : ''
|
||
].filter(Boolean)
|
||
|
||
for (const dir of candidates) {
|
||
const model = join(dir, 'zh_CN-xiao_ya-medium.onnx')
|
||
const lexicon = join(dir, 'lexicon.txt')
|
||
const tokens = join(dir, 'tokens.txt')
|
||
if (existsSync(model) && existsSync(lexicon) && existsSync(tokens)) {
|
||
return dir
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* VITS(小雅)的 sherpa-onnx 校验要求指定一个 espeak-ng-data 目录,历史上直接复用
|
||
* Piper bundle 里的那份数据。Piper 自己的引擎(模型/二进制/onnxruntime/phonemize)
|
||
* 在有 VITS 时永远不会被实际调用(detectBackend 第一优先级永远命中 VITS),
|
||
* 所以只保留 piper/espeak-ng-data 这一个子目录,不要求整套 Piper 引擎存在。
|
||
*/
|
||
function findPiperEspeakDataDir(): string | null {
|
||
const candidates = [
|
||
join(process.resourcesPath, 'tts', 'piper'),
|
||
join(__dirname, '..', '..', 'resources', 'tts', 'piper'),
|
||
process.env.TTS_BUNDLE_DIR ? join(process.env.TTS_BUNDLE_DIR, 'piper') : ''
|
||
].filter(Boolean)
|
||
|
||
for (const dir of candidates) {
|
||
if (existsSync(join(dir, 'espeak-ng-data'))) return dir
|
||
}
|
||
return null
|
||
}
|
||
|
||
function findBundledEspeakNg(): string | null {
|
||
const candidates = [
|
||
join(process.resourcesPath, 'tts', 'espeak-ng'),
|
||
join(__dirname, '..', '..', 'resources', 'tts', 'espeak-ng'),
|
||
process.env.TTS_BUNDLE_DIR ? join(process.env.TTS_BUNDLE_DIR, 'espeak-ng') : ''
|
||
].filter(Boolean)
|
||
|
||
for (const p of candidates) {
|
||
if (existsSync(p)) return p
|
||
}
|
||
return null
|
||
}
|
||
|
||
function checkCommand(cmd: string, env?: NodeJS.ProcessEnv): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
execFile(cmd, ['--version'], { env: { ...process.env, ...env } }, (error) => {
|
||
resolve(!error)
|
||
})
|
||
})
|
||
}
|
||
|
||
async function detectAudioPlayer(): Promise<AudioPlayer | null> {
|
||
if (detectedAudioPlayer) return detectedAudioPlayer
|
||
const candidates: AudioPlayer[] = ['aplay', 'paplay', 'ffplay']
|
||
for (const candidate of candidates) {
|
||
if (await which(candidate)) {
|
||
detectedAudioPlayer = candidate
|
||
return candidate
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function playerArgs(player: AudioPlayer): string[] {
|
||
switch (player) {
|
||
case 'aplay':
|
||
return ['-t', 'wav', '-']
|
||
case 'paplay':
|
||
return ['-']
|
||
case 'ffplay':
|
||
return ['-nodisp', '-autoexit', '-']
|
||
}
|
||
}
|
||
|
||
function playerArgsForFile(player: AudioPlayer, file: string): string[] {
|
||
switch (player) {
|
||
case 'aplay':
|
||
return ['-t', 'wav', file]
|
||
case 'paplay':
|
||
return [file]
|
||
case 'ffplay':
|
||
return ['-nodisp', '-autoexit', file]
|
||
}
|
||
}
|
||
|
||
/** 空闲超过该时长后,下次播放前先唤醒音频通路(略小于实测的 PulseAudio 约 5s 挂起超时) */
|
||
const WAKE_IDLE_THRESHOLD_MS = 4000
|
||
/** 唤醒静音时长。实测 HDMI 通路恢复出声约需 1 秒,取 1.5 秒留余量 */
|
||
const WAKE_SILENCE_MS = 1500
|
||
/** 唤醒播放的最长等待时间,防止播放器异常挂死阻塞播放队列 */
|
||
const WAKE_TIMEOUT_MS = WAKE_SILENCE_MS + 3000
|
||
|
||
/** 播报段间插入的静音停顿时长(叠加在模型自身韵律停顿之上,总停顿时长约 0.2s~0.4s) */
|
||
const PAUSE_LONG_MS = 200
|
||
const PAUSE_SHORT_MS = 100
|
||
|
||
/** 生成指定时长的裸静音 PCM(s16le 单声道) */
|
||
function buildSilenceRaw(durationMs: number, sampleRate: number): Buffer {
|
||
return Buffer.alloc(Math.floor((sampleRate * durationMs) / 1000) * 2)
|
||
}
|
||
|
||
/**
|
||
* 生成一段 22050Hz / 16bit / 单声道的静音 WAV(与 Piper 输出格式一致)。
|
||
*/
|
||
function buildSilenceWav(durationMs: number, sampleRate = 22050): Buffer {
|
||
const dataSize = Math.floor((sampleRate * durationMs) / 1000) * 2
|
||
const header = Buffer.alloc(44)
|
||
header.write('RIFF', 0)
|
||
header.writeUInt32LE(36 + dataSize, 4)
|
||
header.write('WAVE', 8)
|
||
header.write('fmt ', 12)
|
||
header.writeUInt32LE(16, 16)
|
||
header.writeUInt16LE(1, 20) // PCM
|
||
header.writeUInt16LE(1, 22) // mono
|
||
header.writeUInt32LE(sampleRate, 24)
|
||
header.writeUInt32LE(sampleRate * 2, 28) // byte rate
|
||
header.writeUInt16LE(2, 32) // block align
|
||
header.writeUInt16LE(16, 34) // bits per sample
|
||
header.write('data', 36)
|
||
header.writeUInt32LE(dataSize, 40)
|
||
return Buffer.concat([header, Buffer.alloc(dataSize)])
|
||
}
|
||
|
||
/**
|
||
* 解析标准 WAV 文件,返回 PCM 数据与采样率。
|
||
*/
|
||
function extractWavData(buf: Buffer): { data: Buffer; sampleRate: number } {
|
||
let sampleRate = 22050
|
||
let offset = 12
|
||
while (offset + 8 <= buf.length) {
|
||
const id = buf.toString('ascii', offset, offset + 4)
|
||
const size = buf.readUInt32LE(offset + 4)
|
||
if (id === 'fmt ') {
|
||
sampleRate = buf.readUInt32LE(offset + 12)
|
||
} else if (id === 'data') {
|
||
return { data: buf.subarray(offset + 8, offset + 8 + size), sampleRate }
|
||
}
|
||
offset += 8 + size + (size % 2)
|
||
}
|
||
// 解析失败时按 44 字节标准头兜底
|
||
return { data: buf.subarray(44), sampleRate }
|
||
}
|
||
|
||
/**
|
||
* 把一段静音拼到 PCM 语音前面,打包成完整 WAV。
|
||
* 静音与语音在同一条音频流中,硬件在静音期间完成唤醒,语音部分不会被吞。
|
||
*/
|
||
function combineWavWithLeadingSilence(speech: Buffer, sampleRate: number, silenceMs: number): Buffer {
|
||
const silenceSize = Math.floor((sampleRate * silenceMs) / 1000) * 2
|
||
const dataSize = silenceSize + speech.length
|
||
const header = Buffer.alloc(44)
|
||
header.write('RIFF', 0)
|
||
header.writeUInt32LE(36 + dataSize, 4)
|
||
header.write('WAVE', 8)
|
||
header.write('fmt ', 12)
|
||
header.writeUInt32LE(16, 16)
|
||
header.writeUInt16LE(1, 20) // PCM
|
||
header.writeUInt16LE(1, 22) // mono
|
||
header.writeUInt32LE(sampleRate, 24)
|
||
header.writeUInt32LE(sampleRate * 2, 28)
|
||
header.writeUInt16LE(2, 32)
|
||
header.writeUInt16LE(16, 34)
|
||
header.write('data', 36)
|
||
header.writeUInt32LE(dataSize, 40)
|
||
return Buffer.concat([header, Buffer.alloc(silenceSize), speech])
|
||
}
|
||
|
||
/**
|
||
* 通过当前播放器播放一段静音,唤醒被 PulseAudio 挂起的声卡/HDMI 通路。
|
||
* 播放器异常时按超时返回,保证播放队列不被卡死。
|
||
*/
|
||
async function wakeAudioPath(): Promise<void> {
|
||
const player = await detectAudioPlayer()
|
||
if (!player) return
|
||
console.log(`[systemTts] wake audio path with ${WAKE_SILENCE_MS}ms silence (${player})`)
|
||
await new Promise<void>((resolve) => {
|
||
const proc = spawn(player, playerArgs(player), { stdio: ['pipe', 'ignore', 'ignore'] })
|
||
guardStdin(proc)
|
||
let settled = false
|
||
const done = () => {
|
||
if (settled) return
|
||
settled = true
|
||
clearTimeout(timer)
|
||
resolve()
|
||
}
|
||
const timer = setTimeout(() => {
|
||
try {
|
||
proc.kill('SIGTERM')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
done()
|
||
}, WAKE_TIMEOUT_MS)
|
||
proc.on('exit', done)
|
||
proc.on('error', done)
|
||
if (writeToStdin(proc, buildSilenceWav(WAKE_SILENCE_MS))) {
|
||
try {
|
||
proc.stdin?.end()
|
||
} catch {
|
||
// ignore
|
||
}
|
||
} else {
|
||
done()
|
||
}
|
||
})
|
||
}
|
||
|
||
function detectBackend(): Promise<Backend | null> {
|
||
if (detectedBackendPromise) return detectedBackendPromise
|
||
detectedBackendPromise = (async () => {
|
||
// 1. 优先使用内嵌 sherpa-onnx VITS 小雅模型(BZNSYP 标准普通话)。
|
||
// 依赖 Piper bundle 里的 espeak-ng-data 作为 dataDir(sherpa-onnx 校验需要),
|
||
// 不需要 Piper 引擎本身(bin/model/config),见 findPiperEspeakDataDir 注释。
|
||
const bundledVits = findBundledVits()
|
||
const piperDataDir = findPiperEspeakDataDir()
|
||
if (bundledVits && piperDataDir) {
|
||
bundledVitsDir = bundledVits
|
||
bundledPiperDir = piperDataDir
|
||
detectedBackend = 'bundled-vits'
|
||
console.log('[systemTts] detected backend:', detectedBackend, 'at', bundledVitsDir)
|
||
return detectedBackend
|
||
}
|
||
|
||
// 2. 内嵌 Piper(神经网络中文女声,带台湾腔,仅作兜底)
|
||
const bundledPiper = findBundledPiper()
|
||
if (bundledPiper) {
|
||
const player = await detectAudioPlayer()
|
||
if (player) {
|
||
const env: NodeJS.ProcessEnv = {
|
||
LD_LIBRARY_PATH:
|
||
bundledPiper.dir + (process.env.LD_LIBRARY_PATH ? ':' + process.env.LD_LIBRARY_PATH : ''),
|
||
ESPEAK_DATA_PATH: join(bundledPiper.dir, 'espeak-ng-data')
|
||
}
|
||
if (await checkCommand(bundledPiper.bin, env)) {
|
||
bundledPiperBin = bundledPiper.bin
|
||
bundledPiperDir = bundledPiper.dir
|
||
bundledPiperModel = bundledPiper.model
|
||
bundledPiperConfig = bundledPiper.config
|
||
try {
|
||
const cfg = JSON.parse(readFileSync(bundledPiper.config, 'utf-8'))
|
||
bundledPiperSampleRate = cfg?.audio?.sample_rate ?? 22050
|
||
} catch {
|
||
bundledPiperSampleRate = 22050
|
||
}
|
||
detectedBackend = 'bundled-piper'
|
||
console.log('[systemTts] detected backend:', detectedBackend, 'at', bundledPiperBin)
|
||
return detectedBackend
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 内嵌 espeak-ng 兜底
|
||
const bundled = findBundledEspeakNg()
|
||
if (bundled) {
|
||
const dataDir = dirname(bundled)
|
||
const libDir = join(dataDir, 'lib')
|
||
const env: NodeJS.ProcessEnv = {
|
||
ESPEAK_DATA_PATH: dataDir,
|
||
LD_LIBRARY_PATH: libDir + (process.env.LD_LIBRARY_PATH ? ':' + process.env.LD_LIBRARY_PATH : '')
|
||
}
|
||
if (await checkCommand(bundled, env)) {
|
||
bundledEspeakBin = bundled
|
||
bundledEspeakDataDir = dataDir
|
||
bundledEspeakLibDir = libDir
|
||
detectedBackend = 'bundled-espeak-ng'
|
||
console.log('[systemTts] detected backend:', detectedBackend, 'at', bundledEspeakBin)
|
||
return detectedBackend
|
||
}
|
||
}
|
||
|
||
// 3. 系统 TTS 兜底
|
||
const candidates: Array<Exclude<Backend, 'bundled-piper' | 'bundled-espeak-ng'>> = [
|
||
'spd-say',
|
||
'espeak-ng',
|
||
'espeak'
|
||
]
|
||
for (const candidate of candidates) {
|
||
if (await which(candidate)) {
|
||
if (await checkCommand(candidate)) {
|
||
detectedBackend = candidate
|
||
console.log('[systemTts] detected backend:', detectedBackend)
|
||
return detectedBackend
|
||
}
|
||
}
|
||
}
|
||
|
||
detectedBackend = null
|
||
console.log('[systemTts] no system TTS backend available')
|
||
return detectedBackend
|
||
})()
|
||
return detectedBackendPromise
|
||
}
|
||
|
||
function buildPiperCommand(text: string, options: SystemTtsOptions, sentenceSilence = '0.2'): {
|
||
cmd: string
|
||
args: string[]
|
||
options?: SpawnOptions
|
||
} | null {
|
||
if (!bundledPiperBin || !bundledPiperModel || !bundledPiperConfig) return null
|
||
|
||
const rate = options.rate ?? 1
|
||
// Piper length_scale: 1.0 正常,>1 更慢,<1 更快
|
||
const lengthScale = Math.max(0.5, Math.min(2, 1 / rate))
|
||
|
||
const env: NodeJS.ProcessEnv = {
|
||
...process.env,
|
||
LD_LIBRARY_PATH:
|
||
bundledPiperDir! + (process.env.LD_LIBRARY_PATH ? ':' + process.env.LD_LIBRARY_PATH : ''),
|
||
ESPEAK_DATA_PATH: join(bundledPiperDir!, 'espeak-ng-data')
|
||
}
|
||
|
||
return {
|
||
cmd: bundledPiperBin,
|
||
args: [
|
||
'--model',
|
||
bundledPiperModel,
|
||
'--config',
|
||
bundledPiperConfig,
|
||
'--length_scale',
|
||
String(lengthScale),
|
||
'--sentence_silence',
|
||
sentenceSilence,
|
||
'--output_file',
|
||
'-'
|
||
],
|
||
options: { env, stdio: ['pipe', 'pipe', 'pipe'] }
|
||
}
|
||
}
|
||
|
||
function buildEspeakArgs(
|
||
backend: 'bundled-espeak-ng' | 'espeak-ng' | 'espeak',
|
||
text: string,
|
||
options: SystemTtsOptions
|
||
): { cmd: string; args: string[]; options?: SpawnOptions } {
|
||
const rate = options.rate ?? 1
|
||
const volume = options.volume ?? 1
|
||
const pitch = options.pitch ?? 1
|
||
const wpm = Math.round(140 + (rate - 1) * 100)
|
||
const amp = Math.round(50 + volume * 100)
|
||
const espeakPitch = Math.round(((pitch - 0.1) / 1.9) * 99)
|
||
const cmd = backend === 'bundled-espeak-ng' ? bundledEspeakBin! : backend
|
||
|
||
const spawnOptions: SpawnOptions = { stdio: ['ignore', 'ignore', 'pipe'] }
|
||
if (backend === 'bundled-espeak-ng' && bundledEspeakDataDir && bundledEspeakLibDir) {
|
||
spawnOptions.env = {
|
||
...process.env,
|
||
ESPEAK_DATA_PATH: bundledEspeakDataDir,
|
||
LD_LIBRARY_PATH:
|
||
bundledEspeakLibDir + (process.env.LD_LIBRARY_PATH ? ':' + process.env.LD_LIBRARY_PATH : '')
|
||
}
|
||
}
|
||
|
||
const voice = backend === 'espeak' ? 'zh' : 'cmn'
|
||
return {
|
||
cmd,
|
||
args: ['-v', voice, '-s', String(wpm), '-a', String(amp), '-p', String(espeakPitch), text],
|
||
options: spawnOptions
|
||
}
|
||
}
|
||
|
||
function buildCommand(
|
||
backend: Backend,
|
||
text: string,
|
||
options: SystemTtsOptions
|
||
): { cmd: string; args: string[]; options?: SpawnOptions } | null {
|
||
const rate = options.rate ?? 1
|
||
const volume = options.volume ?? 1
|
||
|
||
switch (backend) {
|
||
case 'bundled-vits':
|
||
return null // sherpa-onnx 进程内合成,不走外部命令
|
||
case 'bundled-piper':
|
||
return buildPiperCommand(text, options)
|
||
case 'spd-say': {
|
||
const spdRate = Math.round((rate - 1) * 50)
|
||
const spdVolume = Math.round(volume * 100)
|
||
const args = ['-l', 'zh', '-r', String(spdRate), '-i', String(spdVolume), text]
|
||
if (options.voiceURI) args.push('-t', options.voiceURI)
|
||
return { cmd: 'spd-say', args }
|
||
}
|
||
case 'bundled-espeak-ng':
|
||
case 'espeak-ng':
|
||
case 'espeak': {
|
||
return buildEspeakArgs(backend, text, options)
|
||
}
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
function playNext(): void {
|
||
if (isSpeaking || queue.length === 0) return
|
||
const item = queue.shift()
|
||
if (!item) return
|
||
|
||
void speakInternal(item.text).then(() => {
|
||
item.resolve()
|
||
playNext()
|
||
})
|
||
}
|
||
|
||
async function speakInternal(text: string): Promise<void> {
|
||
const backend = await detectBackend()
|
||
if (!backend) {
|
||
console.error('[systemTts] 无可用 TTS 后端')
|
||
notify('error', { text, error: 'no backend' })
|
||
return
|
||
}
|
||
|
||
// 声卡/HDMI 通路可能已休眠。Piper 合成耗时长(约 1.5 秒),若先播唤醒静音再等合成完
|
||
// 以新流起播,间隙期间通路会重新休眠导致二次吞音,因此 Piper 路径把静音拼进语音同一条流;
|
||
// 其他后端起播快,沿用播前静音唤醒。
|
||
const needWake = Date.now() - lastPlaybackEndAt > WAKE_IDLE_THRESHOLD_MS
|
||
|
||
console.log(`[systemTts] speak (${backend}):`, text.slice(0, 80))
|
||
notify('started', { text })
|
||
|
||
// 渲染层已在停顿标点后断行,按行分段
|
||
const segments = text
|
||
.split('\n')
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0)
|
||
|
||
if (backend === 'bundled-vits') {
|
||
// sherpa-onnx 进程内合成(小雅模型,标准普通话)
|
||
return speakWithVits(segments.length > 0 ? segments : [text], needWake)
|
||
}
|
||
|
||
const command = buildCommand(backend, text, currentOptions)
|
||
if (!command) {
|
||
notify('error', { text, error: 'build command failed' })
|
||
return
|
||
}
|
||
|
||
if (backend === 'bundled-piper') {
|
||
// aplay 可用时走流式路径:按行逐段合成,裸流喂给同一个 aplay,
|
||
// 段间插入精确时长的静音停顿,且全程同一条音频流不会被吞音。
|
||
if (detectedAudioPlayer === 'aplay' && segments.length > 0) {
|
||
const streamCmd = buildPiperCommand(text, currentOptions, '0')
|
||
if (streamCmd) {
|
||
return speakWithPiperStreaming(streamCmd, segments, needWake)
|
||
}
|
||
}
|
||
return speakWithPiper(command, text, needWake)
|
||
}
|
||
|
||
if (needWake) {
|
||
await wakeAudioPath()
|
||
}
|
||
|
||
return new Promise<void>((resolve) => {
|
||
currentProcess = spawn(command.cmd, command.args, command.options)
|
||
|
||
let stderr = ''
|
||
currentProcess.stderr?.on('data', (chunk: Buffer) => {
|
||
stderr += chunk.toString()
|
||
})
|
||
|
||
currentProcess.on('error', (err) => {
|
||
console.error('[systemTts] spawn error:', err)
|
||
cleanup()
|
||
notify('error', { text, error: err.message })
|
||
resolve()
|
||
})
|
||
|
||
currentProcess.on('exit', (code) => {
|
||
cleanup()
|
||
if (code !== 0 && stderr) {
|
||
console.warn('[systemTts] speak stderr:', stderr)
|
||
}
|
||
notify('done', { text })
|
||
resolve()
|
||
})
|
||
|
||
isSpeaking = true
|
||
})
|
||
}
|
||
|
||
let piperTmpCounter = 0
|
||
|
||
/**
|
||
* 分段流式播报(仅 aplay + Piper):
|
||
* - 一次 piper 进程按行逐段合成到临时目录(每行一个 WAV,仅一次模型加载);
|
||
* - 单个 aplay 以裸 PCM 流模式运行,合成出一段就立刻喂一段;
|
||
* - 段间按标点类型插入静音停顿(长停顿 PAUSE_LONG_MS / 短停顿 PAUSE_SHORT_MS);
|
||
* - 全程同一条音频流,需要唤醒时只在流首写一段裸静音,不会出现间隙二次吞音。
|
||
*/
|
||
function speakWithPiperStreaming(
|
||
command: { cmd: string; args: string[]; options?: SpawnOptions },
|
||
segments: string[],
|
||
needWake: boolean
|
||
): Promise<void> {
|
||
return new Promise<void>((resolve) => {
|
||
const sampleRate = bundledPiperSampleRate
|
||
const segDir = mkdtempSync(join(tmpdir(), 'tts-seg-'))
|
||
const myToken = ++ttsStreamToken
|
||
const isAlive = () => myToken === ttsStreamToken && !resolved
|
||
const fullText = segments.join('')
|
||
|
||
let resolved = false
|
||
const done = (error?: string) => {
|
||
if (resolved) return
|
||
resolved = true
|
||
cleanup()
|
||
try {
|
||
rmSync(segDir, { recursive: true, force: true })
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (error) {
|
||
notify('error', { text: fullText, error })
|
||
} else {
|
||
notify('done', { text: fullText })
|
||
}
|
||
resolve()
|
||
}
|
||
|
||
// piper:按行逐段合成,每行输出一个 WAV 到临时目录
|
||
const piperArgs: string[] = []
|
||
for (let i = 0; i < command.args.length; i++) {
|
||
if (command.args[i] === '--output_file') {
|
||
piperArgs.push('--output_dir', segDir)
|
||
i++ // 跳过原本的 '-' 占位
|
||
} else {
|
||
piperArgs.push(command.args[i])
|
||
}
|
||
}
|
||
currentProcess = spawn(command.cmd, piperArgs, command.options)
|
||
guardStdin(currentProcess)
|
||
|
||
// aplay:裸 PCM 流,采样率与模型一致
|
||
currentPlayer = spawn(
|
||
'aplay',
|
||
['-t', 'raw', '-f', 'S16_LE', '-r', String(sampleRate), '-c', '1', '-'],
|
||
{ stdio: ['pipe', 'ignore', 'pipe'] }
|
||
)
|
||
guardStdin(currentPlayer)
|
||
|
||
// 声卡/HDMI 通路休眠时,先在流首写一段裸静音:静音播放期间 piper 正在合成首段,
|
||
// 通路完成唤醒后语音紧接着到达,既不吞音也几乎不增加等待
|
||
if (needWake) {
|
||
writeToStdin(currentPlayer, buildSilenceRaw(WAKE_SILENCE_MS, sampleRate))
|
||
console.log(`[systemTts] stream ${WAKE_SILENCE_MS}ms leading silence to wake audio path`)
|
||
}
|
||
|
||
let piperStderr = ''
|
||
let playerStderr = ''
|
||
let piperExited = false
|
||
currentProcess.stderr?.on('data', (chunk: Buffer) => {
|
||
piperStderr += chunk.toString()
|
||
})
|
||
currentPlayer.stderr?.on('data', (chunk: Buffer) => {
|
||
playerStderr += chunk.toString()
|
||
})
|
||
|
||
currentProcess.on('error', (err) => {
|
||
console.error('[systemTts] piper spawn error:', err)
|
||
done(err.message)
|
||
})
|
||
currentPlayer.on('error', (err) => {
|
||
console.error('[systemTts] player spawn error:', err)
|
||
done(err.message)
|
||
})
|
||
|
||
currentProcess.on('exit', (code) => {
|
||
piperExited = true
|
||
if (code !== 0 && piperStderr) {
|
||
console.warn('[systemTts] piper stderr:', piperStderr)
|
||
}
|
||
if (code !== 0 && !resolved) {
|
||
try {
|
||
currentPlayer?.kill('SIGTERM')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
done('piper failed')
|
||
}
|
||
})
|
||
|
||
currentPlayer.on('exit', (code) => {
|
||
if (code !== 0 && playerStderr) {
|
||
console.warn('[systemTts] player stderr:', playerStderr)
|
||
}
|
||
done(code !== 0 ? 'player failed' : undefined)
|
||
})
|
||
|
||
currentProcess.stdin?.write(segments.join('\n') + '\n', 'utf-8', (err) => {
|
||
if (err) {
|
||
console.error('[systemTts] write to piper stdin failed:', err)
|
||
}
|
||
currentProcess?.stdin?.end()
|
||
})
|
||
|
||
// 喂流循环:按合成产出顺序把各段裸 PCM 写入 aplay,段间插入对应时长的静音
|
||
void (async () => {
|
||
let fed = 0
|
||
while (fed < segments.length && isAlive()) {
|
||
let files: string[] = []
|
||
try {
|
||
files = readdirSync(segDir)
|
||
.filter((f) => f.endsWith('.wav'))
|
||
.sort()
|
||
} catch {
|
||
break
|
||
}
|
||
// 文件写入完成判定:已有更新的分段文件,或 piper 已整体退出
|
||
if (files.length > fed && (piperExited || files.length > fed + 1)) {
|
||
try {
|
||
const wav = readFileSync(join(segDir, files[fed]))
|
||
const { data } = extractWavData(wav)
|
||
// 播放器可能刚被 stop() kill,写入失败说明本轮播报已终止
|
||
if (!writeToStdin(currentPlayer, data)) break
|
||
if (fed < segments.length - 1) {
|
||
const longPause = /[。;!?!?]\s*$/.test(segments[fed])
|
||
writeToStdin(
|
||
currentPlayer,
|
||
buildSilenceRaw(longPause ? PAUSE_LONG_MS : PAUSE_SHORT_MS, sampleRate)
|
||
)
|
||
}
|
||
fed++
|
||
} catch {
|
||
// 文件可能尚未写完,稍后重试
|
||
}
|
||
} else {
|
||
await new Promise((r) => setTimeout(r, 60))
|
||
}
|
||
}
|
||
if (isAlive()) {
|
||
try {
|
||
currentPlayer?.stdin?.end()
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
})()
|
||
|
||
isSpeaking = true
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 解析 TTS worker 脚本路径(对齐 ASR worker 的查找顺序)。
|
||
*/
|
||
function resolveVitsWorkerScriptPath(): string {
|
||
// electron-vite 生产构建后 worker 脚本会输出到 out/main/
|
||
const builtPathJs = join(__dirname, 'tts-worker.js')
|
||
if (existsSync(builtPathJs)) {
|
||
return builtPathJs
|
||
}
|
||
const builtPathCjs = join(__dirname, 'tts-worker.cjs')
|
||
if (existsSync(builtPathCjs)) {
|
||
return builtPathCjs
|
||
}
|
||
// 开发模式:项目根目录 resources/tts-worker.cjs
|
||
const devPath = join(process.cwd(), 'resources', 'tts-worker.cjs')
|
||
if (existsSync(devPath)) {
|
||
return devPath
|
||
}
|
||
// electron-builder 打包目录
|
||
const prodPath = join(process.resourcesPath, 'tts-worker.cjs')
|
||
if (existsSync(prodPath)) {
|
||
return prodPath
|
||
}
|
||
throw new Error('找不到 TTS worker 脚本')
|
||
}
|
||
|
||
/**
|
||
* 懒启动 TTS worker 并加载模型(首次约 4 秒,之后 worker 常驻复用)。
|
||
*/
|
||
async function ensureVitsWorker(): Promise<void> {
|
||
if (vitsWorker && !vitsWorker.killed && vitsWorker.connected && vitsWorkerReady) return
|
||
if (vitsWorkerInitPromise) return vitsWorkerInitPromise
|
||
|
||
vitsWorkerInitPromise = new Promise<void>((resolve, reject) => {
|
||
const scriptPath = resolveVitsWorkerScriptPath()
|
||
console.log('[systemTts] 启动 TTS worker:', scriptPath)
|
||
|
||
const w = fork(scriptPath, [], {
|
||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||
execPath: process.execPath,
|
||
env: {
|
||
...process.env,
|
||
// 让 Electron 以类 Node.js 模式运行子进程,确保 native addon 可加载
|
||
ELECTRON_RUN_AS_NODE: '1'
|
||
}
|
||
})
|
||
vitsWorker = w
|
||
vitsWorkerReady = false
|
||
|
||
const timeout = setTimeout(() => {
|
||
reject(new Error('TTS worker 初始化超时'))
|
||
}, 30000)
|
||
|
||
w.on('message', (msg: unknown) => {
|
||
if (!msg || typeof msg !== 'object') return
|
||
const m = msg as { type?: string; sampleRate?: number; error?: string; index?: number }
|
||
if (!m.type) return
|
||
if (m.type === 'ready') {
|
||
vitsWorkerReady = true
|
||
vitsSampleRate = m.sampleRate ?? 22050
|
||
clearTimeout(timeout)
|
||
resolve()
|
||
} else if (m.type === 'segment') {
|
||
vitsSegmentHandler?.(m.index ?? -1)
|
||
} else if (m.type === 'error') {
|
||
vitsErrorHandler?.(m.error ?? 'unknown')
|
||
}
|
||
})
|
||
w.stdout?.on('data', (data: Buffer) => {
|
||
const line = data.toString().trim()
|
||
if (line) console.log('[tts-worker]', line)
|
||
})
|
||
w.stderr?.on('data', (data: Buffer) => {
|
||
const line = data.toString().trim()
|
||
if (line) console.error('[tts-worker err]', line)
|
||
})
|
||
w.on('exit', (code) => {
|
||
console.warn('[systemTts] TTS worker 退出:', code)
|
||
vitsWorker = null
|
||
vitsWorkerReady = false
|
||
vitsWorkerInitPromise = null
|
||
vitsErrorHandler?.('worker exited')
|
||
})
|
||
|
||
w.send({
|
||
cmd: 'init',
|
||
modelDir: bundledVitsDir,
|
||
dataDir: join(bundledPiperDir!, 'espeak-ng-data'),
|
||
numThreads: 2
|
||
})
|
||
})
|
||
|
||
try {
|
||
await vitsWorkerInitPromise
|
||
} catch (err) {
|
||
vitsWorkerInitPromise = null
|
||
throw err
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 分段流式播报(sherpa-onnx VITS 小雅模型,标准普通话):
|
||
* - 合成在独立 worker 进程中进行(避免与主进程 onnxruntime-node 冲突);
|
||
* - 主进程把各段裸 PCM 顺序喂给同一个 aplay 流,段间插入对应时长的静音停顿;
|
||
* - 需要唤醒时流首写裸静音,与首段合成时间重叠,几乎零额外等待。
|
||
* worker 初始化失败时回退到 Piper(huayan)。
|
||
*/
|
||
function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
|
||
return new Promise<void>((resolve) => {
|
||
const fullText = segments.join('')
|
||
const finish = (error?: string) => {
|
||
if (error) {
|
||
notify('error', { text: fullText, error })
|
||
} else {
|
||
notify('done', { text: fullText })
|
||
}
|
||
resolve()
|
||
}
|
||
|
||
void (async () => {
|
||
try {
|
||
await ensureVitsWorker()
|
||
} catch (err) {
|
||
console.error('[systemTts] vits worker 初始化失败,回退到 Piper:', err)
|
||
if (bundledPiperBin) {
|
||
const cmd = buildPiperCommand(fullText, currentOptions, '0.2')
|
||
if (cmd) {
|
||
await speakWithPiper(cmd, fullText, needWake)
|
||
finish()
|
||
return
|
||
}
|
||
}
|
||
finish('vits init failed')
|
||
return
|
||
}
|
||
|
||
const player = await detectAudioPlayer()
|
||
if (!player) {
|
||
finish('no audio player')
|
||
return
|
||
}
|
||
// 非 aplay 播放器极少出现,回退到 Piper 文件播放路径
|
||
if (player !== 'aplay') {
|
||
const cmd = buildPiperCommand(fullText, currentOptions, '0.2')
|
||
if (cmd) {
|
||
await speakWithPiper(cmd, fullText, needWake)
|
||
finish()
|
||
return
|
||
}
|
||
finish('aplay required for vits')
|
||
return
|
||
}
|
||
|
||
const sampleRate = vitsSampleRate
|
||
const speed = Math.max(0.5, Math.min(2, currentOptions.rate ?? 1))
|
||
const gain = Math.max(0, Math.min(1, currentOptions.volume ?? 1))
|
||
const myToken = ++ttsStreamToken
|
||
const segDir = mkdtempSync(join(tmpdir(), 'tts-vits-'))
|
||
|
||
let resolved = false
|
||
const isAlive = () => myToken === ttsStreamToken && !resolved
|
||
const done = (error?: string) => {
|
||
if (resolved) return
|
||
resolved = true
|
||
// 通知 worker 停止后续合成(模型保持加载,worker 常驻不退出)
|
||
try {
|
||
vitsWorker?.send({ cmd: 'cancel' })
|
||
} catch {
|
||
// ignore
|
||
}
|
||
vitsSegmentHandler = null
|
||
vitsErrorHandler = null
|
||
cleanup()
|
||
try {
|
||
rmSync(segDir, { recursive: true, force: true })
|
||
} catch {
|
||
// ignore
|
||
}
|
||
finish(error)
|
||
}
|
||
|
||
currentPlayer = spawn(
|
||
'aplay',
|
||
['-t', 'raw', '-f', 'S16_LE', '-r', String(sampleRate), '-c', '1', '-'],
|
||
{ stdio: ['pipe', 'ignore', 'pipe'] }
|
||
)
|
||
guardStdin(currentPlayer)
|
||
|
||
if (needWake) {
|
||
writeToStdin(currentPlayer, buildSilenceRaw(WAKE_SILENCE_MS, sampleRate))
|
||
console.log(`[systemTts] stream ${WAKE_SILENCE_MS}ms leading silence to wake audio path`)
|
||
}
|
||
|
||
let playerStderr = ''
|
||
currentPlayer.stderr?.on('data', (chunk: Buffer) => {
|
||
playerStderr += chunk.toString()
|
||
})
|
||
currentPlayer.on('error', (err) => {
|
||
console.error('[systemTts] player spawn error:', err)
|
||
done(err.message)
|
||
})
|
||
currentPlayer.on('exit', (code) => {
|
||
if (code !== 0 && playerStderr) {
|
||
console.warn('[systemTts] player stderr:', playerStderr)
|
||
}
|
||
done(code !== 0 ? 'player failed' : undefined)
|
||
})
|
||
|
||
// 分段到达跟踪
|
||
const arrived = new Set<number>()
|
||
let lastProgressAt = Date.now()
|
||
vitsSegmentHandler = (idx) => {
|
||
arrived.add(idx)
|
||
lastProgressAt = Date.now()
|
||
}
|
||
vitsErrorHandler = (err) => {
|
||
console.warn('[systemTts] TTS worker error:', err)
|
||
lastProgressAt = Date.now()
|
||
}
|
||
|
||
vitsWorker!.send({ cmd: 'synth', dir: segDir, segments, speed, gain })
|
||
|
||
// 喂流循环:分段就绪一段喂一段,段间插入对应时长的静音
|
||
void (async () => {
|
||
let fed = 0
|
||
while (fed < segments.length && isAlive()) {
|
||
if (arrived.has(fed)) {
|
||
try {
|
||
const pcm = readFileSync(join(segDir, `seg-${fed}.pcm`))
|
||
// 播放器可能刚被 stop() kill,写入失败说明本轮播报已终止
|
||
if (!writeToStdin(currentPlayer, pcm)) break
|
||
if (fed < segments.length - 1) {
|
||
const longPause = /[。;!?!?]\s*$/.test(segments[fed])
|
||
writeToStdin(
|
||
currentPlayer,
|
||
buildSilenceRaw(longPause ? PAUSE_LONG_MS : PAUSE_SHORT_MS, sampleRate)
|
||
)
|
||
}
|
||
fed++
|
||
lastProgressAt = Date.now()
|
||
} catch {
|
||
// 文件可能尚未写完,稍后重试
|
||
await new Promise((r) => setTimeout(r, 30))
|
||
}
|
||
} else {
|
||
if (Date.now() - lastProgressAt > 60000) {
|
||
// 合成卡死:杀掉 worker(下次播报会重新拉起)并结束本轮
|
||
try {
|
||
vitsWorker?.kill('SIGTERM')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
done('synth timeout')
|
||
return
|
||
}
|
||
await new Promise((r) => setTimeout(r, 50))
|
||
}
|
||
}
|
||
if (isAlive()) {
|
||
try {
|
||
currentPlayer?.stdin?.end()
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
})()
|
||
|
||
isSpeaking = true
|
||
})().catch((err) => {
|
||
console.error('[systemTts] speakWithVits error:', err)
|
||
finish(String(err))
|
||
})
|
||
})
|
||
}
|
||
|
||
function speakWithPiper(
|
||
command: { cmd: string; args: string[]; options?: SpawnOptions },
|
||
text: string,
|
||
withLeadingSilence: boolean
|
||
): Promise<void> {
|
||
return new Promise<void>((resolve) => {
|
||
const player = detectedAudioPlayer
|
||
if (!player) {
|
||
notify('error', { text, error: 'no audio player' })
|
||
resolve()
|
||
return
|
||
}
|
||
|
||
const seq = ++piperTmpCounter
|
||
const synthFile = join(tmpdir(), `tts-synth-${process.pid}-${seq}.wav`)
|
||
const wakeFile = join(tmpdir(), `tts-wake-${process.pid}-${seq}.wav`)
|
||
|
||
let resolved = false
|
||
const done = (error?: string) => {
|
||
if (resolved) return
|
||
resolved = true
|
||
cleanup()
|
||
for (const f of [synthFile, wakeFile]) {
|
||
try {
|
||
unlinkSync(f)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
if (error) {
|
||
notify('error', { text, error })
|
||
} else {
|
||
notify('done', { text })
|
||
}
|
||
resolve()
|
||
}
|
||
|
||
// 先合成到临时文件,再整体播放:
|
||
// 需要唤醒时把静音拼进同一条音频流,避免“唤醒静音→合成间隙→新流”造成的二次吞音
|
||
const piperArgs = command.args.slice(0, -1).concat(synthFile)
|
||
currentProcess = spawn(command.cmd, piperArgs, command.options)
|
||
guardStdin(currentProcess)
|
||
|
||
let piperStderr = ''
|
||
currentProcess.stderr?.on('data', (chunk: Buffer) => {
|
||
piperStderr += chunk.toString()
|
||
})
|
||
|
||
currentProcess.on('error', (err) => {
|
||
console.error('[systemTts] piper spawn error:', err)
|
||
done(err.message)
|
||
})
|
||
|
||
currentProcess.on('exit', (code) => {
|
||
if (resolved) return
|
||
if (code !== 0) {
|
||
if (piperStderr) {
|
||
console.warn('[systemTts] piper stderr:', piperStderr)
|
||
}
|
||
done('piper failed')
|
||
return
|
||
}
|
||
|
||
let fileToPlay = synthFile
|
||
if (withLeadingSilence) {
|
||
try {
|
||
const wav = readFileSync(synthFile)
|
||
const { data, sampleRate } = extractWavData(wav)
|
||
writeFileSync(wakeFile, combineWavWithLeadingSilence(data, sampleRate, WAKE_SILENCE_MS))
|
||
fileToPlay = wakeFile
|
||
console.log(`[systemTts] prepend ${WAKE_SILENCE_MS}ms silence to wake audio path`)
|
||
} catch (err) {
|
||
console.warn('[systemTts] 拼接唤醒静音失败,直接播放原始语音:', err)
|
||
}
|
||
}
|
||
|
||
currentPlayer = spawn(player, playerArgsForFile(player, fileToPlay), {
|
||
stdio: ['ignore', 'ignore', 'pipe']
|
||
})
|
||
|
||
let playerStderr = ''
|
||
currentPlayer.stderr?.on('data', (chunk: Buffer) => {
|
||
playerStderr += chunk.toString()
|
||
})
|
||
|
||
currentPlayer.on('error', (err) => {
|
||
console.error('[systemTts] player spawn error:', err)
|
||
done(err.message)
|
||
})
|
||
|
||
currentPlayer.on('exit', (playerCode) => {
|
||
if (playerCode !== 0 && playerStderr) {
|
||
console.warn('[systemTts] player stderr:', playerStderr)
|
||
}
|
||
done(playerCode !== 0 ? 'player failed' : undefined)
|
||
})
|
||
})
|
||
|
||
currentProcess.stdin?.write(text, 'utf-8', (err) => {
|
||
if (err) {
|
||
console.error('[systemTts] write to piper stdin failed:', err)
|
||
}
|
||
currentProcess?.stdin?.end()
|
||
})
|
||
|
||
isSpeaking = true
|
||
})
|
||
}
|
||
|
||
function cleanup(): void {
|
||
currentProcess = null
|
||
currentPlayer = null
|
||
isSpeaking = false
|
||
lastPlaybackEndAt = Date.now()
|
||
}
|
||
|
||
/**
|
||
* 给子进程 stdin 挂 error 监听。进程被 kill 后管道断开(EPIPE),
|
||
* 未监听的 'error' 事件会抛为未捕获异常,导致 Electron 主进程弹出
|
||
* "A JavaScript error occurred in the main process" 对话框。
|
||
*/
|
||
function guardStdin(proc: ChildProcess | null): void {
|
||
proc?.stdin?.on('error', (err: NodeJS.ErrnoException) => {
|
||
if (err?.code !== 'EPIPE') {
|
||
console.warn('[systemTts] child stdin error:', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 安全写入子进程 stdin:进程可能刚被 kill、管道已断开,写入前检查可写状态 */
|
||
function writeToStdin(proc: ChildProcess | null, data: Buffer | string): boolean {
|
||
const stdin = proc?.stdin
|
||
if (!stdin || stdin.destroyed || !stdin.writable) return false
|
||
try {
|
||
stdin.write(data)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
export function configureSystemTts(options: SystemTtsOptions): void {
|
||
currentOptions = { ...currentOptions, ...options }
|
||
}
|
||
|
||
export async function systemTtsSpeak(text: string): Promise<void> {
|
||
if (!text.trim()) return
|
||
return new Promise<void>((resolve) => {
|
||
queue.push({ text: text.trim(), resolve })
|
||
playNext()
|
||
})
|
||
}
|
||
|
||
export function systemTtsStop(): void {
|
||
// 递增代际标记,进行中的分段喂流循环会据此中止
|
||
ttsStreamToken++
|
||
// 先销毁 stdin 再 kill:否则喂流循环在 kill 后的间隙仍可能向已断开的管道写入,
|
||
// 触发 EPIPE 未捕获异常(Electron 弹出 "A JavaScript error occurred" 对话框)
|
||
if (currentProcess) {
|
||
try {
|
||
currentProcess.stdin?.destroy()
|
||
} catch {
|
||
// ignore
|
||
}
|
||
try {
|
||
currentProcess.kill('SIGTERM')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
currentProcess = null
|
||
}
|
||
if (currentPlayer) {
|
||
try {
|
||
currentPlayer.stdin?.destroy()
|
||
} catch {
|
||
// ignore
|
||
}
|
||
try {
|
||
currentPlayer.kill('SIGTERM')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
currentPlayer = null
|
||
}
|
||
queue = []
|
||
isSpeaking = false
|
||
// 注意:这里不能刷新 lastPlaybackEndAt。播报前渲染进程总会先调一次 stop(),
|
||
// 若此时没有在播内容,刷新时间戳会让"空闲时长"判断永远失效,唤醒逻辑永远不会触发。
|
||
// 被 kill 的播放进程退出时会经 cleanup() 记录结束时间,无需在此处理。
|
||
}
|
||
|
||
export async function systemTtsAvailable(): Promise<boolean> {
|
||
return (await detectBackend()) !== null
|
||
}
|