Skip to content

Commit 8d28984

Browse files
committed
feat(classroom): P0-2 Silero VAD 升级(主进程 onnxruntime + 渲染精判集成)
- 主进程 sileroVadService:onnxruntime-node 跑 silero_vad_v5(有状态流式),IPC vad_silero_process 返回语音概率;模型从 node_modules 包内读取(dev/asar 均可用),加载/推理失败静默降级 - 渲染进程 IpcSileroVad:在途合并(保留最新块)、概率时间戳缓存、不可用降级;注入式 SileroProbSource 接口 - VADMarker 集成:噪声抑制(暂存-确认两段式,语音开头不丢)+ 静音复核(低能量语音尾推迟分段,3000ms 上限防拉长)+ reset 联动 - smartCaptureController:创建概率源/双喂/释放接线 - 选主进程 onnxruntime-node 而非渲染 onnxruntime-web:规避 file:// 下 wasm/worker/COI 坑(2026-08 AudioWorklet 事故教训) - 新增 24 例单测(噪声抑制/静音复核/推迟上限/合并/降级)
1 parent d72b920 commit 8d28984

12 files changed

Lines changed: 901 additions & 18 deletions

File tree

‎client/electron/ai/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { registerChatHandlers } from './handlers/chatHandler.js';
1515
import { registerTtsHandlers } from './handlers/ttsHandler.js';
1616
import { registerLocalAsrHandlers } from './local-asr/index.js';
1717
import { loadLocalAsrConfig } from './local-asr/config.js';
18+
import { registerVadHandlers } from './vad/sileroVadService.js';
1819

1920
// 导入所有 AI 功能模块
2021
import { feature as summarizeFeature } from './handlers/summarizeHandler.js';
@@ -98,6 +99,9 @@ export function registerAIHandlers(): void {
9899
// 注册本地 ASR(sherpa-onnx)IPC handler
99100
registerLocalAsrHandlers();
100101

102+
// 注册本地 Silero VAD IPC handler(P0-2,主进程 onnxruntime 推理)
103+
registerVadHandlers();
104+
101105
// 注册流式输出 IPC handler
102106
registerStreamHandler();
103107

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
}

‎client/electron/ipc/channels.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export const IPC_CHANNELS = {
4949
AUDIO_CAPTURE_DO_START: 'audio_capture_do_start',
5050
AUDIO_CAPTURE_DO_STOP: 'audio_capture_do_stop',
5151

52+
// 本地 Silero VAD(主进程 onnxruntime 推理,P0-2)
53+
VAD_SILERO_PROCESS: 'vad_silero_process',
54+
5255
// Path C 视频录制
5356
VIDEO_RECORD_START: 'video_record_start',
5457
VIDEO_RECORD_STOP: 'video_record_stop',

‎client/electron/preload.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ const ALLOWED_CHANNELS = [
132132
'local_asr_stream_available',
133133
'local_asr_stream_start',
134134
'local_asr_stream_stop',
135+
// 本地 Silero VAD(主进程 onnxruntime 推理,P0-2)
136+
'vad_silero_process',
135137
// MCP 学习记忆服务器应用内授权开关
136138
'memory_server:get_consent',
137139
'memory_server:set_consent',

0 commit comments

Comments
 (0)