- 识别:按题动态 grammar(前缀锁定因数)、只输出中文数字、发音兼容别名(n/l 不分、四十↔十四混淆重录) - 新增双人抢答:键盘/蓝牙手柄绑定抢答、A 秒硬截止扣分、实时计分与冠军结算,支持顺序/随机出题 - 全局口诀范围设置(几开头~几开头,localStorage 持久化) - 修复退出比赛残留定时器弹结果卡的 bug(epoch 代际守卫);计时条改 rAF 逐帧驱动 - 新增 eval.js 端到端识别回归评测(135 例 100%) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
107 lines
4.9 KiB
JavaScript
107 lines
4.9 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 识别流水线端到端回归评测(零 npm 依赖,需先启动 server.js)
|
||
* 用 macOS `say` 合成语音 → ffmpeg 转 webm → POST /stt?a=&b= → 用 index.html 里的真实判分函数打分
|
||
*
|
||
* 用法:
|
||
* node eval.js # 45 题 × (完整口诀 / 只说得数 / 错误答案) 全量
|
||
* QUICK=1 node eval.js # 抽样跑(约 1/5)
|
||
* VOICES="Tingting,Shelley (中文(中国大陆))" node eval.js # 多音色
|
||
* (注意:Shelley/Flo 等同名有英/中两版,必须用带「(中文(中国大陆))」的全名,
|
||
* 否则 `say` 会选英文版,念中文出垃圾音频,被 grammar 强扭成错误定值)
|
||
* SERVER=http://localhost:8000 node eval.js
|
||
*
|
||
* 判定标准:
|
||
* 正确说法 → 前端决策必须是 correct(retry/wrong 都算失败)
|
||
* 错误说法 → 前端决策必须不是 correct(retry/wrong 都算通过,防“把答错扶正”)
|
||
*/
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const vm = require('vm');
|
||
const { spawnSync } = require('child_process');
|
||
|
||
const SERVER = process.env.SERVER || 'http://localhost:8000';
|
||
const VOICES = (process.env.VOICES || 'Tingting').split(',').map(s => s.trim()).filter(Boolean);
|
||
const QUICK = !!process.env.QUICK;
|
||
|
||
// ---- 从 index.html 抠出纯函数(CONFIG/口诀数据/解析/判分),在 vm 里执行 ----
|
||
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
|
||
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||
const cut = script.indexOf('/* ============ 语音播放');
|
||
if (cut < 0) { console.error('index.html 结构变了:找不到「语音播放」分节注释'); process.exit(1); }
|
||
const ctx = {};
|
||
vm.createContext(ctx);
|
||
vm.runInContext(script.slice(0, cut), ctx);
|
||
const { judge, isJustFactors, isTeenDroppedTen, isSwapConfusion, hasNumber, buildProblems, kouOf, numToCn } = ctx;
|
||
|
||
// 复刻前端 onRecStop 的决策分支顺序
|
||
function decide(text, a, b, p) {
|
||
if (judge(text, a, b, p) === true) return 'correct';
|
||
if (isJustFactors(text, a, b)) return 'retry';
|
||
if (isTeenDroppedTen(text, a, b, p) || isSwapConfusion(text, a, b, p)) return 'retry';
|
||
if (hasNumber(text)) return 'wrong';
|
||
return 'retry';
|
||
}
|
||
|
||
function synth(text, voice) {
|
||
const aiff = path.join(os.tmpdir(), 'calx-eval.aiff');
|
||
const webm = path.join(os.tmpdir(), 'calx-eval.webm');
|
||
let r = spawnSync('say', ['-v', voice, text, '-o', aiff]);
|
||
if (r.status !== 0) return null;
|
||
r = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', aiff, '-c:a', 'libopus', webm]);
|
||
if (r.status !== 0) return null;
|
||
return fs.readFileSync(webm);
|
||
}
|
||
|
||
async function stt(buf, a, b) {
|
||
const r = await fetch(`${SERVER}/stt?a=${a}&b=${b}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'audio/webm' }, body: buf,
|
||
});
|
||
return r.json();
|
||
}
|
||
|
||
async function main() {
|
||
try { const h = await (await fetch(SERVER + '/health')).json(); console.log('server:', JSON.stringify(h)); }
|
||
catch (e) { console.error(`连不上 ${SERVER},先 bash start.sh`); process.exit(1); }
|
||
|
||
let problems = buildProblems();
|
||
if (QUICK) problems = problems.filter((_, i) => i % 5 === 0);
|
||
|
||
// 每题三类用例:完整口诀 / 只说得数 / 错误答案(期望不被判对)
|
||
const cases = [];
|
||
for (const { a, b, p } of problems) {
|
||
const wrong = p === 1 ? 3 : p - 1;
|
||
cases.push({ a, b, p, cat: 'kou', say: kouOf(a, b), wantCorrect: true });
|
||
cases.push({ a, b, p, cat: 'ans', say: numToCn(p), wantCorrect: true });
|
||
cases.push({ a, b, p, cat: 'neg', say: numToCn(wrong), wantCorrect: false });
|
||
}
|
||
|
||
const stat = {}; const fails = []; let msSum = 0, n = 0;
|
||
for (const voice of VOICES) {
|
||
for (const c of cases) {
|
||
const buf = synth(c.say, voice);
|
||
if (!buf) { console.error(`合成失败: ${voice} "${c.say}"`); continue; }
|
||
const j = await stt(buf, c.a, c.b);
|
||
const d = decide(j.text || '', c.a, c.b, c.p);
|
||
const pass = c.wantCorrect ? d === 'correct' : d !== 'correct';
|
||
msSum += j.ms || 0; n++;
|
||
stat[c.cat] = stat[c.cat] || { pass: 0, total: 0 };
|
||
stat[c.cat].total++; if (pass) stat[c.cat].pass++;
|
||
if (!pass) fails.push(` ✗ [${voice}/${c.cat}] ${c.a}×${c.b}=${c.p} 说"${c.say}" → 识别"${j.text}" → 判定 ${d}`);
|
||
process.stdout.write(pass ? '.' : 'F');
|
||
}
|
||
}
|
||
console.log('\n');
|
||
if (fails.length) { console.log('失败用例:'); fails.forEach(f => console.log(f)); console.log(); }
|
||
for (const cat of Object.keys(stat)) {
|
||
const s = stat[cat];
|
||
const name = { kou: '完整口诀', ans: '只说得数', neg: '错误答案不被判对' }[cat] || cat;
|
||
console.log(`${name}: ${s.pass}/${s.total} (${(s.pass / s.total * 100).toFixed(1)}%)`);
|
||
}
|
||
console.log(`平均识别耗时: ${n ? Math.round(msSum / n) : 0}ms 共 ${n} 例 音色: ${VOICES.join(',')}`);
|
||
const allPass = Object.values(stat).every(s => s.pass === s.total);
|
||
process.exit(allPass ? 0 : 1);
|
||
}
|
||
main();
|