elec_rag_ui/src/main/tts/systemTts.ts
ssurui d512ad41f9 perf: 精简打包体积,砍掉从未被使用的 Piper 引擎和渲染层专用依赖
排查确认两处真实浪费:

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>
2026-08-23 00:19:14 +08:00

1223 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}