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:
41
server.js
41
server.js
@@ -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 : ''}`);
|
||||
|
||||
Reference in New Issue
Block a user