创建日期:2026-10-13 | 最近更新:2026-10-13 事实核对基于 sherpa-onnx 1.13.8(
sherpa-onnx-node;node v24.19.0、Linux x64)。所有耗时与文本输出均为本机实测,脚本与完整日志留档于source/sherpa-onnx-lab/。
sherpa-onnx 101(第 2 篇 · 落地):流式识别、端点检测与长音频
模型选好了,接下来是工程问题:音频怎么喂进去、什么时候算一句话说完、几十分钟的录音怎么处理。
这一篇把这三件事讲透,而且给两个真实的坑——一个是官方 API 层面的(reset 会吃掉你的结果),一个是架构层面的(VAD 切段更快,但会切在句子中间)。
0. 先给结论
| # | 结论 | 证据 |
|---|---|---|
| 1 | 流式的用法是轮询:喂一块 → while (isReady) decode() → getResult() 拿当前文本。不需要自己做 VAD 就能边出字 | §1 |
| 2 | 首字延迟实测 1.1~1.6 秒(chunk 480 ms 的模型),之后每 0.4~0.5 秒刷新一次 | §1.2 |
| 3 | 端点检测触发时 reset() 会清空当前结果,只在结尾取一次 getResult() 的话,整句会变成空字符串 | §2.2 |
| 4 | 长音频用 VAD 切段比整条流式快 37%(RTF 0.065 对 0.104),但会切在句子中间丢字——两种切法输出的文本没有一句完全相同 | §3 |
| 5 | VAD 本身极便宜:RTF 0.0085,起点精度约 ±30 ms 量级,可以放心用来做门控 | §4 |
| 6 | 采样率不匹配不用自己处理:24 kHz 的 wav 丢进 16 kHz 的识别器会被自动重采样(日志里能看到) | §5 |
1. 流式识别:最小可用写法
流式的核心循环只有四行,但要理解它在干什么:
const stream = recognizer.createStream();
// 1) 喂一块音频(100 ms 一包是我用的粒度)
stream.acceptWaveform({ sampleRate: wave.sampleRate, samples: chunkSamples });
// 2) 把当前能算的都算完——用 while 而不是 if,因为一次可能攒了好几帧
while (recognizer.isReady(stream)) recognizer.decode(stream);
// 3) 取当前文本。这是"到目前为止"的结果,后面会继续变长
const text = recognizer.getResult(stream).text;
关键点:getResult() 返回的是增量式的当前最优解,不是最终结果。同一个 stream 反复 decode,文本会一个字一个字长出来。
1.1 完整脚本
const sherpa = require('sherpa-onnx-node');
const recognizer = new sherpa.OnlineRecognizer({
featConfig: { sampleRate: 16000, featureDim: 80 },
modelConfig: {
transducer: { encoder: '.../encoder.int8.onnx', decoder: '.../decoder.onnx', joiner: '.../joiner.int8.onnx' },
tokens: '.../tokens.txt',
numThreads: 2,
provider: 'cpu',
debug: false,
},
decodingMethod: 'greedy_search',
enableEndpoint: true, // 打开端点检测,见 §2
rule1MinTrailingSilence: 2.4, // 静音 2.4s 且无语音 → 一"轮"结束
rule2MinTrailingSilence: 1.2, // 静音 1.2s 且中间有语音 → 一句结束
rule3MinUtteranceLength: 20, // 一句话超过 20s 强制断开
});
const wave = sherpa.readWave('test.wav');
const stream = recognizer.createStream();
const chunk = Math.round(0.1 * wave.sampleRate); // 100 ms
for (let i = 0; i + chunk <= wave.samples.length; i += chunk) {
stream.acceptWaveform({
sampleRate: wave.sampleRate,
samples: wave.samples.subarray(i, i + chunk),
});
while (recognizer.isReady(stream)) recognizer.decode(stream);
console.log(recognizer.getResult(stream).text);
}
1.2 它到底"边出字"到什么程度
把每次文本变化的时刻记下来(10.05 秒的音频,模型是 X-ASR 480ms):
t=1.60s "昨天"
t=2.10s "昨天是"
t=3.10s "昨天是 MON"
t=3.60s "昨天是 MONDA"
t=4.00s "昨天是 MONDAY"
t=5.00s "昨天是 MONDAY TODAY"
t=6.00s "昨天是 MONDAY TODAY IS 礼"
t=6.40s "昨天是 MONDAY TODAY IS 礼拜"
t=6.90s "昨天是 MONDAY TODAY IS 礼拜二"
t=7.90s "昨天是 MONDAY TODAY IS 礼拜二 THE DAY"
...
两件事值得注意:
- 首字在 1.6 秒。这是模型的 chunk 延迟(480 ms)+ 一句真正开始说话前的静音共同造成的。如果你做的是"按下说话"的输入框,这 1.6 秒的反馈延迟会被用户感知到——这种场景更适合先本地 VAD 检测到人开始说话再提示"正在听"。
- 英文是逐字母长出来的(
MON→MONDA→MONDAY)。因为模型用的是子词(BPE)词表,中间态会停在半个词上。做 UI 时要接受"半截单词"的闪烁,别在 partial 文本上做高亮或正则匹配。
getResult() 还能拿到更多东西——包括每个 token 的时间戳,这是做逐字对齐/卡拉OK 式高亮的依据:
const r = recognizer.getResult(stream);
// r.text 最终文本
// r.tokens [" 八"," USE"," EFFECT"," 依", ...] ← 中英混排时英文 token 带前导空格
// r.timestamps [0.96, 1.16, 1.44, 2.08, ...] ← 秒
// r.ys_probs 每个 token 的声学概率(可以拿来做置信度)
注意 r.tokens 里的前导空格是有意义的(表示词边界),但中文 token 上也可能有,直接拼字符串会多出空格——要显示就用 r.text,要用 token 就自己按需处理。
2. 端点检测:什么时候算"一句话说完了"
enableEndpoint: true 之后,三条规则会同时生效:
| 规则 | 含义 | 默认值 |
|---|---|---|
rule1MinTrailingSilence | 静音超过这么久,且整段都没有语音 → 结束 | 2.4 s |
rule2MinTrailingSilence | 静音超过这么久,且中间出现过语音 → 结束 | 1.2 s |
rule3MinUtteranceLength | 单句长度上限,超了强制断开 | 20 s |
判定方式是每喂完一块就查一次:
if (recognizer.isEndpoint(stream)) {
const text = recognizer.getResult(stream).text; // ⚠️ 必须先取
if (text) committed.push(text);
recognizer.reset(stream); // ⚠️ 后清
}
2.1 三条规则为什么是 2.4 / 1.2 / 20
这三个数值背后是语音识别的经验值:说话人自己换气的停顿通常 0.3~0.8 秒,说完一段话的停顿 1 秒以上。所以 1.2 秒作为"一句结束"是个不错的默认。2.4 秒那条更宽松,用来判"这轮对话结束了"(比如做连续对话时清空上下文)。
但默认值不一定适合你。我在长音频实验里把 rule2 调到 0.6 秒,结果 4 段音频被切成了 6 句——因为语音里本来就有接近 0.6 秒的正常停顿。如果你把它调得太激进,就会把一句话拦腰截断。
2.2 坑:reset() 会吃掉你的最后一句
这是我第一次跑流式时踩的坑,症状很吓人:一段 8.8 秒的音频,识别结果是空字符串。
原因在于 reset(stream) 不只是"重新开始",它会把当前 stream 的结果一起清掉。而端点触发之后,被断开的那句话就再也取不回来了。所以如果你只在循环结束后取一次结果:
// ❌ 错的:端点时 reset 掉,最后只取一次
if (recognizer.isEndpoint(stream)) {
recognizer.reset(stream); // 文本从此消失
}
// ...循环结束
const text = recognizer.getResult(stream).text; // ""
这个错误行为可以用同一段音频对比出来(脚本 04b-endpoint-trap.js):
正确写法(端点时收走文本): "第一句是个什么时态加了 YES 是一般现代时对我们把它时态写上去"
错误写法(端点时直接 reset): ""
本次音频触发端点数 : 1
一句话总结:端点触发时,先 getResult() 收文本,再 reset()。 这个顺序不能反,也不能省。
3. 长音频:两种切法,实测对比
几十分钟的录音不可能整体丢进去(内存和延迟都不允许)。有两条路:
- 整条丢给流式识别器,靠端点检测自动断句;
- 先用 VAD 切成语音段,再逐段识别。
我把 4 段测试音频拼成一条 31.07 秒的长音频(段间插 0.6 秒静音,于是真实句边界是已知的:0.0010.05、10.6515.75、16.3521.04、21.6430.47),两种方式各跑一遍。
3.1 方式一:整条流式(靠端点检测)
耗时 3.25 s RTF 0.105 切出 6 句
1. 昨天是
2. MONDAY TODAY
3. IS 礼拜二
4. THE DAY AFTER TOMORROW 是星期三
5. 这是第一种第二种叫呃与 ALWAYS ALWAYS 什么意思啊这个是频繁的啊不认识记下来 FREQUENTLY 频繁的
6. 第一句是个什么时态加了 YES 是一般现在时对我们把它时态写上去
问题一眼可见:第 5 句把两段不同的音频粘成了一句(这个 和 这是 之间本来是 0.6 秒静音,没达到 1.2 秒的阈值)。而第 1~4 句本来是同一个人连续说的一段话,被内部停顿切成了四块——语义上它是一句"昨天是 MONDAY TODAY IS 礼拜二 THE DAY AFTER TOMORROW 是星期三"。
3.2 方式二:VAD 切段 + 逐段识别
VAD 耗时 0.22 s (RTF 0.0071) 切出 6 段
ASR 耗时 1.82 s 合计 2.04 s RTF 0.066
1. [0.68s] 昨天是
2. [3.81s] TODAY IS
3. [6.53s] THE DAY AFTER TOMORROW 是
4. [11.49s] 这是第一种第二种叫呃与 ALWAYS ALWAYS
5. [16.36s] 就是平凡的啊不认识接下来 FREQUENTLY
6. [22.28s] 第一句是个什么时态加了 YES 是一般现在时我们把它
VAD 的切点非常准:11.49 / 16.36 / 22.28 三个起点对应真实边界 10.65 / 16.35 / 21.64,误差在 1 秒内(第一个边界差得多是因为 VAD 起点是语音起点而非静音起点)。
代价也很明显:第 2 句丢掉了 "MONDAY",第 3 句丢了 "礼拜二" 和 "星期三" 的尾巴——因为 VAD 切在了句子内部的停顿上,模型每个片段都失去了上下文。
3.3 两条路的账
| 整条流式 + 端点 | VAD 切段 + 逐段识别 | |
|---|---|---|
| 总耗时 | 3.25 s | 2.04 s |
| RTF | 0.105 | 0.066(快 37%) |
| 上下文 | 保留 | 被切断,丢字 |
| 跨句粘连 | 有(0.6 s 停顿不够触发端点) | 无 |
| 适合 | 实时字幕、连续语音 | 批量转写超长录音、只关心"有没有内容" |
结论不是"哪个更好",而是"它们解决不同问题":
- 实时场景用流式 + 端点,把
rule2MinTrailingSilence调到你的说话习惯(0.8~1.5 秒之间试),别指望默认值; - 批量转写用 VAD 做两级:先用 VAD 把静音大段扔掉(省算力),但切点要留出余量(前后各留 0.2~0.3 秒音频),别贴着边界切,否则首尾音素必然丢。
补充一句:我这里的"切点丢字"完全是 VAD 贴着语音边界切的后果。真实工程里做批量转写通常会按固定长度切(比如 20~30 秒)并留 0.5 秒重叠,而不是按 VAD 边界切——这样既不丢字,也能控制单块内存。
4. VAD 本身有多便宜
单独量一下(Silero VAD,0.6 MB 模型):
| 指标 | 值 |
|---|---|
| RTF | 0.0085(约 117 倍实时) |
| 起点与能量包络参照之差 | −32 ms |
| 窗口 | 512 样本 = 32 ms(模型固定) |
| 加载后 RSS | 74 MB |
117 倍实时意味着 VAD 可以无脑跑:你在前端做"检测到说话才开始录音""静音自动停止"这类功能,VAD 的开销可以忽略不计。真正贵的是后面的识别模型。
注意 windowSize 不能改(Silero 固定 512,TenVAD 固定 256),喂进去的块大小必须刚好等于它。用 CircularBuffer 来攒数据比自己拼数组可靠:
const buffer = new sherpa.CircularBuffer(30 * 16000); // 30 秒环形缓冲
buffer.push(resampledChunk);
while (buffer.size() >= vad.config.sileroVad.windowSize) {
vad.acceptWaveform(buffer.get(buffer.head(), windowSize));
buffer.pop(windowSize);
}
5. 采样率:大部分情况不用你操心
麦克风常用 44.1/48 kHz,而模型要 16 kHz。sherpa-onnx 内部会自动重采样——我在跑 Moonshine 时日志里出现了这个(原音频是 24 kHz):
offline-stream.cc:AcceptWaveformImpl:142 Creating a resampler:
in_sample_rate: 24000
output_sample_rate: 16000
所以 stream.acceptWaveform({ sampleRate: 24000, samples }) 直接喂就行,结果和先自己重采样到 16 kHz 一致(我实测 Moonshine 在 24 kHz 输入上跑出了 RTF 0.026)。
两个例外要注意:
featConfig.sampleRate必须是模型训练时的采样率(一般是 16000),它不是你音频的采样率;- 麦克风实时流里,每次
acceptWaveform的 sampleRate 要一致,混着传会得到莫名其妙的结果。如果你要手动重采样,用官方那个:
const resampler = new sherpa.LinearResampler(nativeSampleRate, 16000);
const resampled = resampler.resample(float32Chunk); // Float32Array
6. 一条能抄的流水线骨架
把上面所有东西串起来,一个"麦克风 → 本地识别"的骨架长这样:
const cpal = require('node-cpal');
const sherpa = require('sherpa-onnx-node');
const vad = new sherpa.Vad({ /* 见第 0 篇 */ }, 60);
const recognizer = new sherpa.OnlineRecognizer({ /* 见 §1.1 */ });
const buffer = new sherpa.CircularBuffer(30 * 16000);
const win = vad.config.sileroVad.windowSize;
const stream = recognizer.createStream();
const committed = [];
const device = cpal.getDefaultInputDevice();
const cfg = cpal.getDefaultInputConfig(device.deviceId);
const resampler = new sherpa.LinearResampler(cfg.sampleRate, 16000);
const HOLD_WINDOWS = Math.round(1.0 / (win / 16000)); // 语音结束后再多喂约 1 秒
let silenceRun = HOLD_WINDOWS;
const input = cpal.createStream(device.deviceId, true,
{ sampleRate: cfg.sampleRate, channels: 1, format: 'f32' }, (data) => {
// ① 重采样到模型采样率,攒进环形缓冲
buffer.push(resampler.resample(data));
// ② 按 VAD 的固定窗口切开,逐窗喂 VAD(顺便当门控)
while (buffer.size() >= win) {
const chunk = buffer.get(buffer.head(), win);
buffer.pop(win);
vad.acceptWaveform(chunk);
silenceRun = vad.isDetected() ? 0 : silenceRun + 1;
// ③ 语音中(或刚结束的一小段)才喂给识别器——喂的是原始连续音频,
// 不是 VAD 切出来的段,所以模型始终有上下文
if (silenceRun <= HOLD_WINDOWS) {
stream.acceptWaveform({ sampleRate: 16000, samples: chunk });
while (recognizer.isReady(stream)) recognizer.decode(stream);
if (recognizer.isEndpoint(stream)) {
const text = recognizer.getResult(stream).text;
if (text) committed.push(text); // 先取
recognizer.reset(stream); // 后清
}
}
}
// ④ 从 VAD 的段队列里拿"整段语音"(用于落盘、算时长等,不参与识别)
while (!vad.isEmpty()) {
const seg = vad.front();
vad.pop();
// seg.samples / seg.start 随你用
}
});
三个设计决定值得说明:
- VAD 只做门控,不做切分——第 ③ 步喂给识别器的是连续音频,不是 VAD 切出来的段。这样识别器始终有上下文,避免了 §3 里的丢字问题;VAD 只负责回答"现在要不要算"。注意
silenceRun的滞后(HOLD_WINDOWS):语音刚结束时不能立刻停喂,否则句尾会被切掉。 - 环形缓冲用固定容量(30 秒)。麦克风数据是无界的,没有缓冲上限就会内存泄漏。
committed是唯一的结果出口。别去 stream 里"最后再取一次",那个时机很难对。
下一件事:这套链路能听懂普通话,但听不懂
useEffect。第 3 篇用热词和同音词替换,把你们组的黑话教给它。