Files
jev-match-game/scripts/verify-ondevice-asr.ts
T

176 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 端侧小模型转写(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 已知缺口)');