elec_rag_ui/resources/tts-worker.cjs
ssurui 6f93912c86 feat: 初始提交——elec_rag_ui 独立前端工程
基于 Electron + Vue3 + TypeScript + LanceDB 的本地化 RAG 问答桌面应用。
仅支持 Linux(Kylin V10 / Ubuntu),适配 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)计算卡。
服务进程管理委托给 er_backend(HTTP 17700),前端专注 RAG 逻辑与用户交互。

包含:
- src/elec_rag_ui 前端工程(Electron + Vue3)
- models/ 本地嵌入模型(bge-small/base/large,ASR,标点恢复,onnx 文件由 Git LFS 管理)
- docs/ 示例文档(用于测试索引与问答)
2026-08-15 19:25:28 +08:00

105 lines
3.3 KiB
JavaScript
Raw Permalink 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.

// TTS worker:独立进程加载 sherpa-onnx-node,避免与主进程的 onnxruntime-node 发生 DLL 冲突。
// 以 ELECTRON_RUN_AS_NODE=1 启动,不能依赖 electron.app。
//
// IPC 协议(JSON 消息):
// -> { cmd: 'init', modelDir, dataDir, numThreads } 初始化模型(约 4 秒,之后常驻)
// <- { type: 'ready', sampleRate } | { type: 'error', error }
// -> { cmd: 'synth', dir, segments: string[], speed, gain } 逐段合成到 dir/seg-i.pcm
// <- { type: 'segment', index } 每段完成(失败段写空文件保证顺序推进)
// <- { type: 'synthDone' } | { type: 'error', error }
// -> { cmd: 'cancel' } 停止后续分段合成(当前段做完为止,模型保持加载)
// -> { cmd: 'quit' }
const fs = require('fs')
const path = require('path')
let tts = null
let cancelled = false
function send(msg) {
if (process.send) {
process.send(msg)
}
}
async function handleInit(msg) {
const sherpa = require('sherpa-onnx-node')
tts = new sherpa.OfflineTts({
model: {
vits: {
model: path.join(msg.modelDir, 'zh_CN-xiao_ya-medium.onnx'),
lexicon: path.join(msg.modelDir, 'lexicon.txt'),
tokens: path.join(msg.modelDir, 'tokens.txt'),
// sherpa-onnx 校验要求 dataDir 指向 espeak-ng-data(由主进程指定)
dataDir: msg.dataDir
},
numThreads: msg.numThreads || 2,
debug: false,
provider: 'cpu'
},
maxNumSentences: 1
})
send({ type: 'ready', sampleRate: tts.sampleRate })
}
async function handleSynth(msg) {
if (!tts) {
send({ type: 'error', error: 'not initialized' })
return
}
cancelled = false
const speed = typeof msg.speed === 'number' ? msg.speed : 1
const gain = typeof msg.gain === 'number' ? msg.gain : 1
for (let i = 0; i < msg.segments.length; i++) {
if (cancelled) break
const file = path.join(msg.dir, 'seg-' + i + '.pcm')
try {
// Electron(V8 沙箱)下必须以拷贝方式返回音频,禁用外部缓冲
const audio = await tts.generateAsync({
text: msg.segments[i],
sid: 0,
speed,
enableExternalBuffer: false
})
if (cancelled) break
const samples = audio.samples
const pcm = Buffer.alloc(samples.length * 2)
for (let j = 0; j < samples.length; j++) {
const s = Math.max(-1, Math.min(1, samples[j] * gain))
pcm.writeInt16LE(Math.round(s * 32767), j * 2)
}
fs.writeFileSync(file, pcm)
} catch (e) {
if (cancelled && e.code === 'ENOENT') break
// 失败段写空文件,保证主进程喂流顺序不被卡住
console.error('[tts-worker] synth segment failed:', e)
try {
fs.writeFileSync(file, Buffer.alloc(0))
} catch {
// ignore
}
}
send({ type: 'segment', index: i })
if (cancelled) break
}
send({ type: 'synthDone' })
}
process.on('message', (msg) => {
if (!msg || typeof msg !== 'object') return
if (msg.cmd === 'init') {
handleInit(msg).catch((e) => send({ type: 'error', error: String(e) }))
} else if (msg.cmd === 'synth') {
handleSynth(msg).catch((e) => send({ type: 'error', error: String(e) }))
} else if (msg.cmd === 'cancel') {
cancelled = true
} else if (msg.cmd === 'quit') {
process.exit(0)
}
})
process.on('disconnect', () => {
process.exit(0)
})