|
| 1 | +/** |
| 2 | + * 本地 Silero VAD 服务(主进程,onnxruntime-node) |
| 3 | + * |
| 4 | + * @ai-context: 在主进程用 onnxruntime-node 运行 silero_vad_v5 模型(有状态 |
| 5 | + * 流式 VAD),渲染进程经 IPC `vad_silero_process` 喂入 PCM 块换取语音概率。 |
| 6 | + * 选主进程而非渲染进程 onnxruntime-web 的原因:Electron 生产环境 file:// |
| 7 | + * 协议下 wasm 加载与 worker/COI 均不可靠(见 docs/knowledge/bugs/ |
| 8 | + * 2026-08-classroom-asr-file-protocol-worklet-load-failure.md 的教训—— |
| 9 | + * "API 可用"≠"资源可用"),Node 环境加载本地模型无此问题。 |
| 10 | + * @ai-context EN: Silero v5 VAD runs in the Electron main process via |
| 11 | + * onnxruntime-node. The renderer feeds PCM chunks over IPC and receives |
| 12 | + * speech probabilities. Main-process hosting avoids the wasm/worker/COI |
| 13 | + * pitfalls of file:// in production builds. |
| 14 | + * @ai-context: 模型文件从 node_modules 包内读取(@ricky0123/vad-web 的 |
| 15 | + * dist/silero_vad_v5.onnx),dev 与 asar 打包后路径均可命中(fs 对 asar |
| 16 | + * 透明)。加载/推理任一失败均静默降级为 unavailable,渲染进程回退纯 RMS |
| 17 | + * VAD(本地优先原则下的优雅降级,不阻塞采集)。 |
| 18 | + */ |
| 19 | + |
| 20 | +import path from 'path'; |
| 21 | +import fs from 'fs'; |
| 22 | +import { app } from 'electron'; |
| 23 | +import { safeHandle } from '../../ipcUtils.js'; |
| 24 | +import { IPC_CHANNELS } from '../../ipc/channels.js'; |
| 25 | +import { logger } from '../../logger.js'; |
| 26 | + |
| 27 | +// ================================================================ |
| 28 | +// 懒加载状态 |
| 29 | +// ================================================================ |
| 30 | + |
| 31 | +/** onnxruntime InferenceSession 实例(懒加载单例) */ |
| 32 | +let _session: import('onnxruntime-node').InferenceSession | null = null; |
| 33 | +/** Silero v5 有状态推理的 state 张量 [2,1,128] */ |
| 34 | +let _state: import('onnxruntime-node').Tensor | null = null; |
| 35 | +/** 加载是否已永久失败(避免反复重试) */ |
| 36 | +let _loadFailed = false; |
| 37 | +/** 进行中的加载 Promise(并发去重) */ |
| 38 | +let _loadPromise: Promise<boolean> | null = null; |
| 39 | + |
| 40 | +/** 解析 silero_vad_v5.onnx 模型路径(dev = client/node_modules;prod = asar 内 node_modules) */ |
| 41 | +function resolveModelPath(): string { |
| 42 | + return path.join( |
| 43 | + app.getAppPath(), |
| 44 | + 'node_modules', |
| 45 | + '@ricky0123', |
| 46 | + 'vad-web', |
| 47 | + 'dist', |
| 48 | + 'silero_vad_v5.onnx', |
| 49 | + ); |
| 50 | +} |
| 51 | + |
| 52 | +/** 懒加载模型与会话;返回是否可用。失败静默(渲染进程降级 RMS) */ |
| 53 | +function ensureLoaded(): Promise<boolean> { |
| 54 | + if (_session && _state) return Promise.resolve(true); |
| 55 | + if (_loadFailed) return Promise.resolve(false); |
| 56 | + if (_loadPromise) return _loadPromise; |
| 57 | + |
| 58 | + _loadPromise = (async (): Promise<boolean> => { |
| 59 | + try { |
| 60 | + // 与 sherpa-onnx-node 同款动态加载模式(可选依赖失败不崩溃) |
| 61 | + // eslint-disable-next-line @typescript-eslint/no-require-imports |
| 62 | + const ort = require('onnxruntime-node') as typeof import('onnxruntime-node'); |
| 63 | + const modelPath = resolveModelPath(); |
| 64 | + if (!fs.existsSync(modelPath)) { |
| 65 | + throw new Error(`Silero 模型文件不存在: ${modelPath}`); |
| 66 | + } |
| 67 | + _session = await ort.InferenceSession.create(modelPath, { |
| 68 | + executionProviders: ['cpu'], |
| 69 | + graphOptimizationLevel: 'all', |
| 70 | + }); |
| 71 | + // 初始 state:零张量 [2,1,128](与 @ricky0123/vad-web 的 SileroV5 初始化一致) |
| 72 | + _state = new ort.Tensor('float32', new Float32Array(2 * 1 * 128), [2, 1, 128]); |
| 73 | + logger.info(`[SileroVAD] 模型加载成功: ${modelPath}`); |
| 74 | + return true; |
| 75 | + } catch (err) { |
| 76 | + _loadFailed = true; |
| 77 | + _session = null; |
| 78 | + _state = null; |
| 79 | + logger.warn(`[SileroVAD] 模型加载失败,VAD 降级纯 RMS: ${err}`); |
| 80 | + return false; |
| 81 | + } |
| 82 | + })(); |
| 83 | + return _loadPromise; |
| 84 | +} |
| 85 | + |
| 86 | +/** 重置流状态(新会话开始时调用,state 归零) */ |
| 87 | +function resetState(): void { |
| 88 | + _state = null; |
| 89 | + if (_session) { |
| 90 | + try { |
| 91 | + // eslint-disable-next-line @typescript-eslint/no-require-imports |
| 92 | + const ort = require('onnxruntime-node') as typeof import('onnxruntime-node'); |
| 93 | + _state = new ort.Tensor('float32', new Float32Array(2 * 1 * 128), [2, 1, 128]); |
| 94 | + } catch { |
| 95 | + _state = null; |
| 96 | + } |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * 整块 PCM 推理,返回语音概率(0-1)。 |
| 102 | + * 输入要求 16kHz 单声道 Float32;块长任意(v5 模型支持可变长输入)。 |
| 103 | + * 推理失败返回 null 并置失效(下次调用重新加载一次)。 |
| 104 | + */ |
| 105 | +async function processPcm(pcm: Float32Array): Promise<number | null> { |
| 106 | + if (!(await ensureLoaded())) return null; |
| 107 | + if (!_session || !_state) return null; |
| 108 | + try { |
| 109 | + // eslint-disable-next-line @typescript-eslint/no-require-imports |
| 110 | + const ort = require('onnxruntime-node') as typeof import('onnxruntime-node'); |
| 111 | + const input = new ort.Tensor('float32', pcm, [1, pcm.length]); |
| 112 | + const sr = new ort.Tensor('int64', new BigInt64Array([16000n]), [1]); |
| 113 | + const results = await _session.run({ input, state: _state, sr }); |
| 114 | + if (!results.stateN || !results.output) { |
| 115 | + logger.warn('[SileroVAD] 推理输出缺失 stateN/output'); |
| 116 | + return null; |
| 117 | + } |
| 118 | + _state = results.stateN; |
| 119 | + const prob = results.output.data[0]; |
| 120 | + return typeof prob === 'number' ? prob : null; |
| 121 | + } catch (err) { |
| 122 | + logger.warn(`[SileroVAD] 推理失败,本次概率置空: ${err}`); |
| 123 | + // 会话对象损坏时释放,下次 process 会重新加载 |
| 124 | + _session = null; |
| 125 | + _state = null; |
| 126 | + return null; |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +// ================================================================ |
| 131 | +// IPC 注册 |
| 132 | +// ================================================================ |
| 133 | + |
| 134 | +/** 注册 Silero VAD IPC handler(ai/index.ts 统一调用) */ |
| 135 | +export function registerVadHandlers(): void { |
| 136 | + safeHandle( |
| 137 | + IPC_CHANNELS.VAD_SILERO_PROCESS, |
| 138 | + async ( |
| 139 | + _event, |
| 140 | + args: { samples: ArrayBuffer; sampleRate?: number; reset?: boolean }, |
| 141 | + ) => { |
| 142 | + if (args?.reset) resetState(); |
| 143 | + const available = await ensureLoaded(); |
| 144 | + if (!available || !args?.samples) { |
| 145 | + return { probability: null, available }; |
| 146 | + } |
| 147 | + if (args.sampleRate && args.sampleRate !== 16000) { |
| 148 | + // P0-5 采样率校验口径:非 16k 直接拒绝(本地引擎要求 16k) |
| 149 | + logger.warn(`[SileroVAD] 非预期采样率: ${args.sampleRate}Hz,跳过推理`); |
| 150 | + return { probability: null, available }; |
| 151 | + } |
| 152 | + const pcm = new Float32Array(args.samples); |
| 153 | + if (pcm.length === 0) return { probability: null, available }; |
| 154 | + const probability = await processPcm(pcm); |
| 155 | + return { probability, available: probability !== null }; |
| 156 | + }, |
| 157 | + ); |
| 158 | + logger.info('[SileroVAD] IPC handler registered (vad_silero_process)'); |
| 159 | +} |
0 commit comments