Files
calx/server.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

188 lines
9.0 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 依赖)
* GET / → 返回 index.html
* POST /stt → 上传录音(webm/mp4/…),用 ffmpeg 转码 + whisper.cpp 离线识别,返回 { text }
*
* 依赖的系统命令(已用 brew 安装ffmpeg、whisper-cli
* 模型:./models/ggml-small.bin默认较准或 ggml-base.bin较快用 WHISPER_MODEL 切换)
*/
const http = require('http');
const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const crypto = require('crypto');
const ROOT = __dirname;
const PORT = process.env.PORT || 8000;
const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
const THREADS = String(Math.min(8, os.cpus().length || 4));
const WHISPER = process.env.WHISPER_BIN || 'whisper-cli';
const FFMPEG = process.env.FFMPEG_BIN || 'ffmpeg';
// 选模型:环境变量优先;否则有 small 用 small没有就用 base
function resolveModel() {
if (process.env.WHISPER_MODEL && fs.existsSync(process.env.WHISPER_MODEL)) return process.env.WHISPER_MODEL;
const small = path.join(ROOT, 'models', 'ggml-small.bin');
const base = path.join(ROOT, 'models', 'ggml-base.bin');
if (fs.existsSync(small)) return small;
if (fs.existsSync(base)) return base;
return small; // 交给运行时报错提示
}
const MODEL = resolveModel();
const GRAMMAR = process.env.WHISPER_GRAMMAR || path.join(ROOT, 'grammar', 'number.gbnf');
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',
};
function tmp(ext) {
return path.join(os.tmpdir(), 'calx-' + process.pid + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext);
}
function run(cmd, args) {
return new Promise((resolve) => {
const p = spawn(cmd, args);
let err = '';
p.stderr.on('data', d => { err += d; });
p.on('error', e => resolve({ code: -1, err: String(e) }));
p.on('close', code => resolve({ code, err }));
});
}
async function transcribe(buf, contentType, a, b) {
const ext = MIME_EXT[(contentType || '').split(';')[0].trim()] || 'webm';
const inFile = tmp(ext);
const wavFile = tmp('wav');
const outBase = wavFile.replace(/\.wav$/, '');
const cleanup = () => [inFile, wavFile, outBase + '.txt'].forEach(f => { try { fs.unlinkSync(f); } catch (e) {} });
try {
fs.writeFileSync(inFile, buf);
// 1) 转 16k 单声道 wav并在前后各补 0.5s 静音
// (关键:短音节如“一/八”若无留白whisper 常丢字或重复成“88”补静音可修复
const ff = await run(FFMPEG, ['-y', '-loglevel', 'error', '-i', inFile,
'-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 (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 = '';
try { text = fs.readFileSync(outBase + '.txt', 'utf8'); } catch (e) {}
text = text.replace(/\s+/g, '').replace(/[,。!?、,.!?]/g, '').trim();
cleanup();
if (w.code !== 0 && !text) return { text: '', error: 'whisper failed: ' + w.err.slice(0, 200) };
return { text };
} catch (e) {
cleanup();
return { text: '', error: String(e) };
}
}
function handler(req, res) {
const url = new URL(req.url, 'http://x');
// ---- 静态页面 ----
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
fs.readFile(path.join(ROOT, 'index.html'), (e, data) => {
if (e) { res.writeHead(404); res.end('index.html not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(data);
});
return;
}
// ---- iOS 下载证书(浏览器打开会提示安装描述文件) ----
if (req.method === 'GET' && url.pathname === '/cert') {
fs.readFile(path.join(ROOT, 'certs', 'calx-cert.cer'), (e, data) => {
if (e) { res.writeHead(404); res.end('cert not found'); return; }
res.writeHead(200, { 'Content-Type': 'application/x-x509-ca-cert', 'Content-Disposition': 'attachment; filename="calx-cert.cer"' });
res.end(data);
});
return;
}
if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, model: path.basename(MODEL), modelExists: fs.existsSync(MODEL) }));
return;
}
// ---- 语音识别 ----
if (req.method === 'POST' && url.pathname === '/stt') {
const chunks = [];
let size = 0;
req.on('data', d => { chunks.push(d); size += d.length; if (size > 12 * 1024 * 1024) req.destroy(); });
req.on('end', async () => {
const buf = Buffer.concat(chunks);
const t0 = Date.now();
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 : ''}`);
});
return;
}
res.writeHead(404); res.end('not found');
}
function lanIP() {
const ifs = os.networkInterfaces();
for (const name of Object.keys(ifs)) for (const i of ifs[name]) if (i.family === 'IPv4' && !i.internal) return i.address;
return 'localhost';
}
const IP = lanIP();
http.createServer(handler).listen(PORT, '0.0.0.0', () => {
console.log(`\n乘法口诀服务已启动`);
console.log(` HTTP : http://localhost:${PORT} http://${IP}:${PORT}`);
console.log(`识别引擎: ${WHISPER} 模型: ${path.basename(MODEL)} ${fs.existsSync(MODEL) ? '✅' : '❌ 模型不存在'} 线程: ${THREADS} 语法约束: ${HAS_GRAMMAR ? '✅' : '❌(用prompt)'}`);
});
// ---- HTTPSiOS 用麦克风必需)----
try {
const key = path.join(ROOT, 'certs', 'key.pem');
const cert = path.join(ROOT, 'certs', 'cert.pem');
if (fs.existsSync(key) && fs.existsSync(cert)) {
https.createServer({ key: fs.readFileSync(key), cert: fs.readFileSync(cert) }, handler)
.listen(HTTPS_PORT, '0.0.0.0', () => {
console.log(` HTTPS: https://${IP}:${HTTPS_PORT} (iOS 用这个,先访问 https://${IP}:${HTTPS_PORT}/cert 安装并信任证书)`);
});
} else {
console.log(' HTTPS: 未启用(缺少 certs/key.pem 或 certs/cert.pem');
}
} catch (e) { console.log(' HTTPS 启动失败:', e.message); }