基于 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/ 示例文档(用于测试索引与问答)
105 lines
3.3 KiB
JavaScript
105 lines
3.3 KiB
JavaScript
// 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)
|
||
})
|