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 | 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 | 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 { return new Promise((resolve) => { execFile(cmd, ['--version'], { env: { ...process.env, ...env } }, (error) => { resolve(!error) }) }) } async function detectAudioPlayer(): Promise { 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 { const player = await detectAudioPlayer() if (!player) return console.log(`[systemTts] wake audio path with ${WAKE_SILENCE_MS}ms silence (${player})`) await new Promise((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 { 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> = [ '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 { 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((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 { return new Promise((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 { if (vitsWorker && !vitsWorker.killed && vitsWorker.connected && vitsWorkerReady) return if (vitsWorkerInitPromise) return vitsWorkerInitPromise vitsWorkerInitPromise = new Promise((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 { return new Promise((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() 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 { return new Promise((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 { if (!text.trim()) return return new Promise((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 { return (await detectBackend()) !== null }