feat: 端侧转写通路(sherpa-onnx WASM + SenseVoiceSmall,音频不出设备)+ 模型获取脚本与接口校验

This commit is contained in:
Jayway Liu
2026-10-04 10:48:42 +08:00
parent bd8242e76b
commit 9ac1162f00
11 changed files with 1158 additions and 1 deletions
+213
View File
@@ -0,0 +1,213 @@
/**
* 端侧 ASR 资产获取:pnpm fetch:asr
*
* 下载 sherpa-onnx WASM 运行时 + SenseVoiceSmall int8 模型,产物落到 public/asr/
* (该目录在 .gitignore 里,模型不进 git;换机器/删了目录就重跑本脚本)。
*
* 资产来源(2026-10-04 核实,大小为当天下载实测字节数):
* - WASM 运行时:HuggingFace 官方 space k2-fsa/web-assembly-vad-asr-sherpa-onnx-zh-zipformer-ctc
* https://huggingface.co/spaces/k2-fsa/web-assembly-vad-asr-sherpa-onnx-zh-zipformer-ctc/resolve/main/<文件名>
* (glue 95,308 B / wasm 11,722,172 B / api 47,391 B)
* 注意:普通 wasm/asr 运行时(GitHub release 的 zipformer/paraformer demo)只导出
* Online(流式)API,Offline 系列不可用;vad-asr 构建才导出 OfflineRecognizer
* (wasm/vad-asr/CMakeLists.txt 的 exported_functions),而 SenseVoice 是离线模型,
* 必须用这一份。文件逐个下载,无需解压 tarball。
* - 模型:GitHub release asr-models
* https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2
* (163,002,883 B;内含 model.int8.onnx 239,233,841 B + tokens.txt 315,894 B)
*
* 关键改造:官方 glue(sherpa-onnx-wasm-main-vad-asr.js)内嵌的 Emscripten 打包清单指向
* zipformer-ctc demo 的 silero_vad + zipformer 模型(367MB .data)。我们把清单改写为只含
* /model.int8.onnx + /tokens.txt 两个文件,并据此生成自己的 .data(两文件直接拼接,
* Emscripten preload 格式 = 原始字节流 + JS 侧清单)。这样运行时一次请求拿到模型包,
* 模型出现在 MEMFS 根路径,OfflineRecognizer 按绝对路径读取。
*
* 产物(public/asr/):
* sherpa-onnx-wasm-main-vad-asr.js glue(清单已改写)
* sherpa-onnx-wasm-main-vad-asr.wasm 运行时 wasm(11,722,172 B,含 VAD + Offline API)
* sherpa-onnx-wasm-main-vad-asr.data 模型预加载包(≈239,549,735 B)
* sherpa-onnx-asr.js 官方 JS API 封装(OfflineRecognizer)
* sense-voice-LICENSE.txt 模型许可(FunASR 模型许可,允许商用需署名)
* manifest.json 资产清单 + 字节数(引擎下载进度与完整性自检用)
*/
import { spawnSync } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT_DIR = path.join(ROOT, 'public', 'asr');
const FORCE = process.argv.includes('--force');
const HF_SPACE =
'https://huggingface.co/spaces/k2-fsa/web-assembly-vad-asr-sherpa-onnx-zh-zipformer-ctc/resolve/main';
const GLUE_NAME = 'sherpa-onnx-wasm-main-vad-asr.js';
const WASM_NAME = 'sherpa-onnx-wasm-main-vad-asr.wasm';
const API_NAME = 'sherpa-onnx-asr.js';
const DATA_NAME = 'sherpa-onnx-wasm-main-vad-asr.data';
const RUNTIME_FILES: Array<{ name: string; bytes: number }> = [
{ name: GLUE_NAME, bytes: 95_308 },
{ name: WASM_NAME, bytes: 11_722_172 },
{ name: API_NAME, bytes: 47_391 },
];
const MODEL_URL =
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2';
const MODEL_BYTES = 163_002_883;
// 模型 tarball 内
const MODEL_ONNX_NAME = 'model.int8.onnx';
const TOKENS_NAME = 'tokens.txt';
const LICENSE_NAME = 'sense-voice-LICENSE.txt';
async function download(url: string, dest: string, expectedBytes: number): Promise<void> {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch(url);
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const out = createWriteStream(dest);
let received = 0;
let logged = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
out.write(value);
received += value.byteLength;
if (received - logged >= 20 * 1024 * 1024) {
logged = received;
console.log(` … ${(received / 1e6).toFixed(0)} MB`);
}
}
await new Promise((resolve, reject) => out.end(resolve as () => void, reject));
if (received !== expectedBytes) {
throw new Error(`大小不符:期望 ${expectedBytes},实际 ${received}(上游资产可能已更新,请核对后改脚本常量)`);
}
return;
} catch (err) {
console.log(` 下载失败(第 ${attempt} 次):${err instanceof Error ? err.message : err}`);
if (attempt === 3) throw err;
}
}
}
async function extract(tarball: string, dir: string): Promise<void> {
const r = spawnSync('tar', ['-xf', tarball, '-C', dir], { stdio: 'inherit' });
if (r.status !== 0) throw new Error(`解压失败:${tarball}`);
}
/** 只含一个顶层目录时返回该目录路径,否则返回 dir 本身 */
async function onlySubdir(dir: string): Promise<string> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory());
return dirs.length === 1 && entries.length === 1 ? path.join(dir, dirs[0].name) : dir;
}
/** 把 glue 里 loadPackage({...}) 的实参整体替换为新的打包清单(括号配平扫描,兼容 key 带不带引号两种生成风格) */
export function patchGlueMetadata(glue: string, files: Array<{ filename: string; start: number; end: number }>, total: number): string {
const anchor = 'loadPackage({';
const start = glue.indexOf(anchor);
if (start === -1) throw new Error('glue 中找不到 loadPackage 调用');
let depth = 0;
for (let i = start + 'loadPackage('.length; i < glue.length; i++) {
const ch = glue[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
const meta = `{files:[${files.map((f) => `{filename:"${f.filename}",start:${f.start},end:${f.end}}`).join(',')}],remote_package_size:${total}}`;
return glue.slice(0, start) + 'loadPackage(' + meta + glue.slice(i + 1);
}
}
}
throw new Error('glue 中 loadPackage 清单括号不配平');
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const manifestPath = path.join(OUT_DIR, 'manifest.json');
if (!FORCE && (await fs.stat(manifestPath).catch(() => null)) !== null) {
console.log('public/asr/ 已存在(--force 可强制重新下载)');
return;
}
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'jev-asr-'));
console.log(`临时目录:${tmp}`);
console.log('\n1/2 下载 WASM 运行时(vad-asr 构建,含 Offline API)');
for (const f of RUNTIME_FILES) {
console.log(` ${HF_SPACE}/${f.name}`);
await download(`${HF_SPACE}/${f.name}`, path.join(tmp, f.name), f.bytes);
}
console.log(`\n2/2 下载 SenseVoiceSmall int8 模型(${(MODEL_BYTES / 1e6).toFixed(0)} MB)\n ${MODEL_URL}`);
const modelTar = path.join(tmp, 'model.tar.bz2');
await download(MODEL_URL, modelTar, MODEL_BYTES);
const modelDir = path.join(tmp, 'model');
await fs.mkdir(modelDir);
await extract(modelTar, modelDir);
const model = await onlySubdir(modelDir);
const glue = await fs.readFile(path.join(tmp, GLUE_NAME), 'utf8');
const wasm = await fs.readFile(path.join(tmp, WASM_NAME));
const api = await fs.readFile(path.join(tmp, API_NAME));
const modelOnnx = await fs.readFile(path.join(model, MODEL_ONNX_NAME));
const tokens = await fs.readFile(path.join(model, TOKENS_NAME));
const licenseText = await fs.readFile(path.join(model, 'LICENSE')).catch(() => null);
// 生成 .data:model.int8.onnx 拼 tokens.txt;清单路径用绝对路径(MEMFS 根)
const data = Buffer.concat([modelOnnx, tokens]);
const patched = patchGlueMetadata(
glue,
[
{ filename: `/${MODEL_ONNX_NAME}`, start: 0, end: modelOnnx.byteLength },
{ filename: `/${TOKENS_NAME}`, start: modelOnnx.byteLength, end: data.byteLength },
],
data.byteLength,
);
if (patched === glue) throw new Error('清单改写失败');
const assets: Array<{ name: string; bytes: number }> = [
{ name: GLUE_NAME, bytes: Buffer.byteLength(patched) },
{ name: WASM_NAME, bytes: wasm.byteLength },
{ name: DATA_NAME, bytes: data.byteLength },
{ name: API_NAME, bytes: api.byteLength },
];
await fs.writeFile(path.join(OUT_DIR, GLUE_NAME), patched);
await fs.writeFile(path.join(OUT_DIR, WASM_NAME), wasm);
await fs.writeFile(path.join(OUT_DIR, DATA_NAME), data);
await fs.writeFile(path.join(OUT_DIR, API_NAME), api);
if (licenseText) await fs.writeFile(path.join(OUT_DIR, LICENSE_NAME), licenseText);
await fs.writeFile(
path.join(OUT_DIR, 'manifest.json'),
JSON.stringify(
{
generatedAt: new Date().toISOString(),
runtime: { source: `${HF_SPACE} (k2-fsa 官方 space,vad-asr 构建)`, note: 'wasm/asr 构建不含 Offline API,SenseVoice 必须用 vad-asr 运行时' },
model: { url: MODEL_URL, name: 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17' },
assets,
},
null,
2,
),
);
// 自检:清单字节数与落盘文件一致
for (const a of assets) {
const actual = (await fs.stat(path.join(OUT_DIR, a.name))).size;
if (actual !== a.bytes) throw new Error(`自检失败:${a.name} 大小 ${actual} != 清单 ${a.bytes}`);
}
await fs.rm(tmp, { recursive: true, force: true });
console.log(`\n完成。产物在 public/asr/(共 ${(assets.reduce((n, a) => n + a.bytes, 0) / 1e6).toFixed(1)} MB):`);
for (const a of assets) console.log(` ${(a.bytes / 1e6).toFixed(2).padStart(8)} MB ${a.name}`);
console.log('\n首次在浏览器/桌面端使用时懒加载到 CacheStorage;见 src/asr/ondevice.ts 头注释。');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+175
View File
@@ -0,0 +1,175 @@
/**
* 端侧小模型转写(sherpa-onnx WASM + SenseVoiceSmall)的 node 侧校验。
*
* WASM 实跑只能在浏览器/WKWebView 里做,这里不断言识别结果,只断言:
* 1. 引擎接口形状:ready/init/transcribe 齐全;无浏览器能力时 init 优雅返回 false;
* 2. mock 引擎跑通接口契约(verify 与真机共用同一形状);
* 3. 长音频切分纯函数:≤28s、连续无重叠无遗漏、优先落静音谷;
* 4. 重采样 48k→16k 的长度与插值正确;
* 5. fetch 脚本的 glue 打包清单改写(括号配平,兼容带/不带引号两种生成风格)。
* 运行:pnpm verify(由 verify.ts 末尾挂链引入)。
*/
import assert from 'node:assert';
import { resampleTo16k } from '../src/asr/capture';
import { SENSE_VOICE_MAX_SEGMENT_SEC, segmentForSenseVoice } from '../src/asr/segment';
import { createMockOndeviceEngine, getOndeviceEngine, ondeviceAsrEnabled } from '../src/asr/ondevice';
import { patchGlueMetadata } from './fetch-ondevice-asr';
let failures = 0;
function check(ok: boolean, msg: string) {
if (!ok) {
failures++;
console.error(`✗ ${msg}`);
} else {
console.log(`✓ ${msg}`);
}
}
const SR = 16000;
// --- 1. 引擎接口形状与优雅降级 ---
{
const engine = getOndeviceEngine();
check(
typeof engine.ready === 'function' && typeof engine.init === 'function' && typeof engine.transcribe === 'function',
'端侧引擎接口形状:ready()/init()/transcribe() 齐全',
);
check(engine.ready() === false, '端侧引擎初始未就绪');
check(ondeviceAsrEnabled() === true, '端侧引擎默认开启(VITE_ONDEVICE_ASR 未设)');
const ok = await engine.init(() => undefined); // node 无 Worker/CacheStorage
check(ok === false && engine.ready() === false, '无浏览器能力时 init 返回 false(静默降级,不抛错)');
let threw = false;
try {
await engine.transcribe(new Float32Array(SR));
} catch {
threw = true;
}
check(threw, '未就绪时 transcribe 抛错(调用方捕获后走降级通路)');
}
// --- 2. mock 引擎契约 ---
{
const mock = createMockOndeviceEngine({ text: '端侧 mock 转写' });
check(mock.ready() === true, 'mock 引擎 ready');
check((await mock.init()) === true, 'mock 引擎 init');
const t = await mock.transcribe(new Float32Array(SR));
check(t.text === '端侧 mock 转写', 'mock 引擎 transcribe 返回文本');
const tagged = createMockOndeviceEngine({ text: 'x', emotion: '<|HAPPY|>', event: '<|BGM|>', lang: '<|zh|>' });
const t2 = await tagged.transcribe(new Float32Array(0));
check(t2.emotion === '<|HAPPY|>' && t2.event === '<|BGM|>' && t2.lang === '<|zh|>', '情感/事件/语种标签可透传(SenseVoice 经 sherpa-onnx 暴露)');
}
// --- 3. 长音频切分 ---
function sine(len: number, freq = 440): Float32Array {
const a = new Float32Array(len);
for (let i = 0; i < len; i++) a[i] = Math.sin((2 * Math.PI * freq * i) / SR);
return a;
}
function concat(segs: Float32Array[]): Float32Array {
const n = segs.reduce((s, x) => s + x.length, 0);
const out = new Float32Array(n);
let o = 0;
for (const s of segs) {
out.set(s, o);
o += s.length;
}
return out;
}
{
const short = sine(10 * SR);
const segs = segmentForSenseVoice(short);
check(segs.length === 1 && segs[0].length === short.length, '10 秒音频不切分(单段原样)');
check(segmentForSenseVoice(new Float32Array(0)).length === 0, '空音频返回空数组');
// 60 秒"讲述":每说 9 秒停 1.5 秒(口语停顿节奏),切点都应落在停顿带里
const SPEECH = 9 * SR;
const PAUSE = 1.5 * SR;
const pcm = new Float32Array(60 * SR);
let cursor = 0;
let utterance = 0;
while (cursor < pcm.length) {
const n = Math.min(SPEECH, pcm.length - cursor);
pcm.set(sine(n, 440 + utterance * 100), cursor); // 不同音高,避免能量巧合
cursor += n;
utterance++;
if (cursor < pcm.length) cursor = Math.min(cursor + PAUSE, pcm.length);
}
const segs2 = segmentForSenseVoice(pcm);
const maxLen = SENSE_VOICE_MAX_SEGMENT_SEC * SR;
check(segs2.length >= 2, `60 秒音频切为 ${segs2.length} 段(≥2)`);
check(segs2.every((s) => s.length <= maxLen), `每段 ≤ ${SENSE_VOICE_MAX_SEGMENT_SEC}s`);
check(concat(segs2).length === pcm.length, '分段拼接后长度等于原音频(无遗漏无重叠)');
let silentCut = true;
let offset = 0;
for (const s of segs2.slice(0, -1)) {
offset += s.length;
// 切点应在停顿带内:向后 200ms 必须仍是静音(切点取停顿起始帧)
let sum = 0;
let n = 0;
for (let i = offset; i < Math.min(pcm.length, offset + 0.2 * SR); i++) {
sum += pcm[i] * pcm[i];
n++;
}
if (n > 0 && Math.sqrt(sum / n) > 0.01) silentCut = false;
}
check(silentCut, '切点落在静音谷(不是词中间)');
// 无静音可找时硬切,仍满足长度与覆盖约束
const solid = sine(90 * SR);
const segs3 = segmentForSenseVoice(solid);
check(segs3.length >= 4 && segs3.every((s) => s.length <= maxLen) && concat(segs3).length === solid.length, '连续无静音音频硬切:段数/长度/覆盖全部满足');
}
// --- 4. 重采样 ---
{
const from = new Float32Array(48000);
for (let i = 0; i < from.length; i++) from[i] = i / 48000; // 线性 ramp,插值可精确验证
const out = resampleTo16k(from, 48000);
check(out.length === 16000, '48k→16k 长度比例正确');
check(Math.abs(out[100] - 100 * 3 / 48000) < 1e-6, '线性插值数值正确');
const same = new Float32Array([0.1, 0.2]);
check(resampleTo16k(same, 16000) === same, '已是 16k 时原样返回(零拷贝)');
}
// --- 5. glue 打包清单改写 ---
{
// v1.13.x glue 风格:key 不带引号
const glueV1 = 'aa loadPackage({files:[{filename:"/.gitignore",start:0,end:0},{filename:"/encoder.onnx",start:0,end:195}],remote_package_size:195}); bb';
// 旧 HF space glue 风格:key 带引号
const glueV2 = 'aa loadPackage({"files":[{"filename":"/a","start":0,"end":1}],"remote_package_size":1})(); bb';
const files = [
{ filename: '/model.int8.onnx', start: 0, end: 100 },
{ filename: '/tokens.txt', start: 100, end: 120 },
];
for (const [name, src] of [['无引号', glueV1], ['带引号', glueV2]] as const) {
const out = patchGlueMetadata(src, files, 120);
check(out.includes('/model.int8.onnx') && out.includes('/tokens.txt') && out.includes('remote_package_size:120'), `glue 清单改写(${name} 风格):文件名与总大小替换成功`);
check(out.includes('/encoder.onnx') === false && out.includes('"/a"') === false, `glue 清单改写(${name} 风格):旧清单清除`);
// 括号配平:loadPackage( 后能找到配平的收尾
const tail = out.slice(out.indexOf('loadPackage(') + 'loadPackage('.length);
let depth = 0;
let balanced = false;
for (const ch of tail) {
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
balanced = true;
break;
}
}
}
check(balanced, `glue 清单改写(${name} 风格):替换后括号配平`);
}
assert.throws(() => patchGlueMetadata('no anchor here', [], 0), /loadPackage/, '无锚点时抛错(上游 glue 结构变了会及时发现)');
console.log('✓ glue 清单改写:无锚点时显式抛错');
}
if (failures > 0) {
console.error(`\n${failures} 项端侧 ASR 校验失败`);
process.exit(1);
}
console.log('\n端侧 ASR 接口形状校验通过(WASM 实跑需真机验证,见 README 已知缺口)');