Files
calx/eval.js
YANG JIANKUAN e42e2202b1 feat: 双人抢答模式 + 识别优化 + PC/Pad 布局重设计
- 识别:按题动态 grammar(前缀锁定因数)、只输出中文数字、发音兼容别名(n/l 不分、四十↔十四混淆重录)
- 新增双人抢答:键盘/蓝牙手柄绑定抢答、A 秒硬截止扣分、实时计分与冠军结算,支持顺序/随机出题
- 全局口诀范围设置(几开头~几开头,localStorage 持久化)
- 修复退出比赛残留定时器弹结果卡的 bug(epoch 代际守卫);计时条改 rAF 逐帧驱动
- 新增 eval.js 端到端识别回归评测(135 例 100%)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:24:28 +08:00

107 lines
4.9 KiB
JavaScript
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.

#!/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
*
* 判定标准:
* 正确说法 → 前端决策必须是 correctretry/wrong 都算失败)
* 错误说法 → 前端决策必须不是 correctretry/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();