/** * 端侧小模型转写(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 已知缺口)');