feat: 双人抢答模式 + 识别优化 + PC/Pad 布局重设计

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 19:24:28 +08:00
parent d975859286
commit e42e2202b1
5 changed files with 709 additions and 136 deletions

View File

@@ -37,6 +37,34 @@ const HAS_GRAMMAR = fs.existsSync(GRAMMAR);
// 备用初始提示(无语法文件时才用),偏向中文数字
const PROMPT = '以下是小学生背诵乘法口诀说出的答案,通常是一个中文数字,例如:一、二、六、九、十二、二十一、四十九、八十一。';
// ---- 按题动态生成 grammar前缀锁死为本题因数得数保持全开放不会把答错扶正----
// 只允许中文数字输出:阿拉伯数字会把「十四/七十四」压扁成 14/74丢掉「十」的信息
const CN = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const GRAMMAR_NUM = [
'product ::= tens | teen | unit | "十"',
'unit ::= "零" | "一" | "二" | "三" | "四" | "五" | "六" | "七" | "八" | "九" | alias',
// 发音兼容别名n/l 不分(六→牛/流/柳/溜)、口语“两”、同音变调(九→酒)。
// 若不给别名,方言发音会被语法硬扭到“错误的”数字(如 niú 更接近 jiǔ「九」
// 给出别名让解码器输出真实听到的音,前端 DIG 表再映射回数字。
'alias ::= "牛" | "流" | "柳" | "溜" | "两" | "酒"',
'teen ::= "十" unit',
'tens ::= unit "十" unit?',
].join('\n');
const dynGrammarCache = new Map();
function dynGrammar(a, b) {
const key = a + 'x' + b;
if (dynGrammarCache.has(key)) return dynGrammarCache.get(key);
const g = [
'root ::= prefix? product',
`prefix ::= ("${CN[a]}${CN[b]}"${a !== b ? ` | "${CN[b]}${CN[a]}"` : ''}) "得"?`,
GRAMMAR_NUM,
].join('\n');
const file = path.join(os.tmpdir(), 'calx-grammar-' + key + '.gbnf');
fs.writeFileSync(file, g);
dynGrammarCache.set(key, file);
return file;
}
const MIME_EXT = {
'audio/webm': 'webm', 'audio/ogg': 'ogg', 'audio/mp4': 'mp4',
'audio/aac': 'aac', 'audio/x-m4a': 'm4a', 'audio/mpeg': 'mp3', 'audio/wav': 'wav',
@@ -55,7 +83,7 @@ function run(cmd, args) {
});
}
async function transcribe(buf, contentType) {
async function transcribe(buf, contentType, a, b) {
const ext = MIME_EXT[(contentType || '').split(';')[0].trim()] || 'webm';
const inFile = tmp(ext);
const wavFile = tmp('wav');
@@ -63,14 +91,16 @@ async function transcribe(buf, contentType) {
const cleanup = () => [inFile, wavFile, outBase + '.txt'].forEach(f => { try { fs.unlinkSync(f); } catch (e) {} });
try {
fs.writeFileSync(inFile, buf);
// 1) 转 16k 单声道 wav并在前后各补 0.3s 静音
// 1) 转 16k 单声道 wav并在前后各补 0.5s 静音
// (关键:短音节如“一/八”若无留白whisper 常丢字或重复成“88”补静音可修复
const ff = await run(FFMPEG, ['-y', '-loglevel', 'error', '-i', inFile,
'-af', 'adelay=300ms:all=1,apad=pad_dur=0.3', '-ar', '16000', '-ac', '1', '-f', 'wav', wavFile]);
'-af', 'adelay=500ms:all=1,apad=pad_dur=0.5', '-ar', '16000', '-ac', '1', '-f', 'wav', wavFile]);
if (ff.code !== 0 || !fs.existsSync(wavFile)) { cleanup(); return { text: '', error: 'ffmpeg failed: ' + ff.err.slice(0, 200) }; }
// 2) whisper.cpp 离线识别:语法约束解码,强制输出成合法数字,杜绝英文/乱码幻听
// 带题目参数时用按题定制的 grammar前缀锁死为本题因数进一步压缩解码错配空间
const grammar = (a >= 1 && a <= 9 && b >= 1 && b <= 9) ? dynGrammar(a, b) : (HAS_GRAMMAR ? GRAMMAR : null);
const args = ['-m', MODEL, '-l', 'zh', '-nt', '-np', '-mc', '0', '-t', THREADS, '-otxt', '-of', outBase, '-f', wavFile];
if (HAS_GRAMMAR) args.splice(6, 0, '--grammar', GRAMMAR, '--grammar-rule', 'root');
if (grammar) args.splice(6, 0, '--grammar', grammar, '--grammar-rule', 'root');
else args.splice(6, 0, '--prompt', PROMPT);
const w = await run(WHISPER, args);
let text = '';
@@ -118,7 +148,8 @@ function handler(req, res) {
req.on('end', async () => {
const buf = Buffer.concat(chunks);
const t0 = Date.now();
const out = await transcribe(buf, req.headers['content-type']);
const a = parseInt(url.searchParams.get('a'), 10), b = parseInt(url.searchParams.get('b'), 10);
const out = await transcribe(buf, req.headers['content-type'], a, b);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ text: out.text || '', ms: Date.now() - t0, error: out.error || null }));
console.log(`[stt] ${size}B ${req.headers['content-type']} -> "${out.text}" ${Date.now() - t0}ms${out.error ? ' ERR:' + out.error : ''}`);