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

@@ -4,13 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
小学生乘法口诀(九九表)语音背诵小程序。核心交互:**自动读题 → 小朋友开口说答案 → 本地离线语音识别判分**全程免手hands-free种模式:顺序闯关、随机挑战(随机模式对错题动态加权)。
小学生乘法口诀(九九表)语音背诵小程序。核心交互:**自动读题 → 小朋友开口说答案 → 本地离线语音识别判分**全程免手hands-free种模式:顺序闯关、随机挑战(随机模式对错题动态加权)、双人抢答(键盘/蓝牙手柄按键抢答,抢到者语音作答,抢答内部又分顺序/随机出题)。**UI 只面向 PC / Pad≥760px不做手机端适配**,风格参考多邻国(高饱和主色 + 厚底 3D 按钮 + 大圆角 + 弹跳动效)
只有两份代码文件:`index.html`(前端单页,含全部 UI/逻辑)和 `server.js`(后端,零 npm 依赖)。没有构建步骤、没有 `package.json`、没有 `node_modules`、没有测试框架。
## Commands
```bash
# 新电脑一键部署(幂等:装依赖 → 补模型 → 生成证书 → 启动,见 DEPLOY.md
bash bootstrap.sh
# 启动HTTP:8000 + HTTPS:8443首次自动生成自签证书
bash start.sh # 或 node server.js
@@ -28,6 +31,11 @@ curl -s -X POST --data-binary @/tmp/t.webm -H "Content-Type: audio/webm" http://
# 切换到更快的 base 模型
WHISPER_MODEL=models/ggml-base.bin node server.js
# 识别准确率回归评测(需先启动服务;改 grammar/判分/whisper 参数后必跑)
node eval.js # 45 题 × (完整口诀/只说得数/错误答案) 全量
QUICK=1 node eval.js # 抽样快跑
VOICES=Tingting,Shelley node eval.js # 多音色
```
**改动 `index.html` 里判分/解析逻辑(`judge`/`parseNumToken`/`extractLastNumber`)后**,务必把纯函数抠出来跑一批中文数字用例(`二十一`/`二一`/`21`/`三七二十一`/`答案是二十一` 等都应判对),这是最易回归的部分。
@@ -45,16 +53,18 @@ WHISPER_MODEL=models/ggml-base.bin node server.js
### 后端 `server.js`(纯 Node `http`/`https`,无框架无依赖)
- 同一个 `handler(req,res)` 同时挂在 HTTP(8000) 和 HTTPS(8443) 上。HTTPS 用 `certs/` 里的自签证书,缺证书则只起 HTTP。
- 路由:`GET /`index.html`GET /cert`iOS 下载证书)、`GET /health``POST /stt`
- `POST /stt` 流水线(`transcribe()`):收原始 body Buffer → 按 `Content-Type` 定扩展名写临时文件 → `ffmpeg` 转 wav `whisper-cli -l zh` 输出 txt → 去标点/空白 → 返回 `{text}`。**前端只拿 `text`,判分在前端做**,后端不碰口诀逻辑。
- `--prompt` 用中文数字示例做偏置,提升「得数」识别率
- `POST /stt?a=&b=` 流水线(`transcribe()`):收原始 body Buffer → 按 `Content-Type` 定扩展名写临时文件 → `ffmpeg` 转 wav(前后各补 0.5s 静音,短音节必需)`whisper-cli -l zh` 语法约束解码输出 txt → 去标点/空白 → 返回 `{text}`。**前端只拿 `text`,判分在前端做**,后端不碰口诀逻辑。
- **grammar 约束解码是识别准确率的命根子**:带 `a`/`b` 参数时用 `dynGrammar()` 按题生成 grammar前缀锁死为本题因数、得数全开放——只约束前缀不会把答错扶正否则退回静态 `grammar/number.gbnf`;没有 grammar 文件才用 `--prompt` 偏置。grammar 只允许中文数字输出——阿拉伯数字会把「十四/七十四」压扁成 14/74 丢掉「十」。grammar 另含发音兼容 `alias` 规则(牛/流/柳/溜/两/酒):不给别名时方言音会被硬扭到错误数字(如 niú 声学上更近 jiǔ「九」给别名让解码器输出真实音、前端 `DIG` 映射回数字
### 前端 `index.html`(单文件,个「页面」用 `.hide` 切换home / game / end
- **口诀数据**`buildProblems()` 生成 45 条三角表a≤b每条带 `weight`(随机模式加权用)。`kouOf()`/`numToCn()` 生成传统口诀读法(如 `三四十二``二五一十`)。
- **判分核心**(改这里要特别小心,容错是重点需求):`judge(transcript,a,b,p)` 先剥掉标点/运算词/口语填充词、再剥掉句首因数前缀(正反序、中/数字),然后 `parseNumToken`/`extractLastNumber` 把中文数字或阿拉伯数字解析成整数与乘积比对。要同时容忍 `二十一`/`二一`/`21`/完整口诀/句尾数字等说法
### 前端 `index.html`(单文件,个「页面」用 `.hide` 切换home / game / match / end
- **口诀数据**`buildProblems()` 生成三角表a≤b每条带 `weight`(随机模式加权用)。`kouOf()`/`numToCn()` 生成传统口诀读法(如 `三四十二``二五一十`)。**全局口诀范围** `CONFIG.tableMin/tableMax`(「几开头」到「几开头」,即限定小因数 a 的区间)过滤题池,三种模式共用,家长设置可改、存 localStorage`calx-range`)。
- **代际守卫 `later(fn,ms)`**:所有“过一会推进游戏”的延迟回调(下一题、自动重录、结果卡自动前进等)必须用 `later` 而不是裸 `setTimeout`——`quit()`/`startGame()`/`enterMatch()`/`matchStart()` 会把 `epoch+1`,旧局排下的回调自动作废。曾有 bug退出比赛后残留回调把「时间到」结果卡弹到首页
- **判分核心**(改这里要特别小心,容错是重点需求):`judge(transcript,a,b,p)``stripKou` 剥掉标点/运算词/口语填充词、再剥掉句首因数前缀(正反序、中/数字),然后 `parseNumToken`/`extractLastNumber` 把中文数字或阿拉伯数字解析成整数与乘积比对。要同时容忍 `二十一`/`二一`/`21`/完整口诀/句尾数字等说法。判分有三条“不冤枉”重录通道:`isJustFactors`(只背了因数没说得数)、`isTeenDroppedTen`(得数十几但只听到个位,如 14 只听到「四」——「十」音弱易被吞)、`isSwapConfusion`(十四↔四十这类平翘舌+语序混淆)。`DIG` 表含**发音兼容别名**(牛/流/柳/溜→6、酒/久→9 等n/l 不分方言),与后端 grammar 的 `alias` 规则配套——加别名要两边同步。
- **免手录音闭环**(关键状态机):`autoAsk()`TTS 读题)→ `startRecordCycle()`MediaRecorder 录音 + `vadTick()` 音量静音检测自动断句)→ `stopRecord()``onRecStop()`(上传 `/stt``judge``resolve`)。没听清会自动重录,上限 `CONFIG.maxAttempts`
- **`resolve(correct,timedOut)`** 是所有作答路径(语音/键盘/超时)的唯一汇合点:更新分数/进度、随机模式调 `updateWeight()`、触发正/负反馈(`positive`/`negative` + 音效 `beep` + 彩带 + 结果卡 `afterSheet`)、再 `nextQuestion()`(顺序模式答错则 `retrySame` 重问同题)。
- **随机错题加权** `updateWeight()`:答错/超时大幅提权、答对且快降权、答对但慢小幅提权;`pickWeighted()` 按权重随机抽题 → 错题/慢题更常出现,练熟自动减少。权重同时看**对错**和**用时**`elapsed` vs `CONFIG.slowMs`)。
- **降级链**:不支持录音 / 麦克风不可用 → 自动切数字键盘(`switchToPad``padMode`/`autoActive` 两个开关联动),键盘作答同样汇入 `resolve()`
- **双人抢答模式**match 页,左右玩家 + 中间出题区):`enterMatch()` 进大厅(可选顺序/随机出题 `matchOrder`,顺序=题池过一遍、随机=`CONFIG.matchQuestions` 题)→ `matchStart()`/`matchNext()` 出题 → 抢答窗口内 `matchKeydown`(键盘)/`padPoll`Gamepad API 轮询,边沿触发)命中绑定键则 `buzz(side)` → 抢到者走同一套录音闭环作答(`buzzAt` 起算的 **A 秒硬截止**:重录只用剩余窗口、到点 `matchOvertime()` 直接判超时,不像单人模式可无限重试)。**作答终局统一走 `settle(correct,timedOut)` 路由**:抢答模式进 `matchResolve`(答对 +5 / 答错超时 2一局结束 `matchFinish` 显示冠军),单人模式进 `resolve`——改录音/判分闭环时两个模式都要过一遍。抢答键绑定 `buzzKeys` 存 localStorage`calx-buzz`支持键盘键码和手柄pad 序号+按钮号);录音 UI 元素(`micBtn`/`heard`/计时条)经 `bindRecUI()` 在 game/match 两页间重指向,数字键盘 DOM 节点在两页间搬移(`mPadSlot`)。
### 前端所有可调参数集中在 `index.html` 顶部的 `CONFIG`
读题开关、限时秒数、随机题数、错题加权系数、VAD 灵敏度(`vadThreshold`/`vadSilenceMs`)、重录次数等。首页「⚙️ 家长设置」面板直接绑定其中常用项。

106
eval.js Normal file
View File

@@ -0,0 +1,106 @@
#!/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();

View File

@@ -1,10 +1,11 @@
# 约束 whisper 只输出「乘法口诀答案」:可选(两因数[得]) + 一个规范数字
# 约束 whisper 只输出「乘法口诀答案」:可选(两因数[得]) + 一个中文数字
# 只允许中文数字:阿拉伯数字会把「十四/七十四」压扁成 14/74丢掉「十」的信息
# alias 是发音兼容别名n/l 不分、口语、同音),前端 DIG 表映射回数字
root ::= prefix? product
prefix ::= sd sd "得"?
product ::= cn | ar
sd ::= "一" | "二" | "三" | "四" | "五" | "六" | "七" | "八" | "九" | [0-9]
cn ::= tens | teen | unit | "十"
unit ::= "零" | "一" | "二" | "三" | "四" | "" | "" | "" | "" | ""
prefix ::= fd fd "得"?
fd ::= "一" | "二" | "三" | "四" | "五" | "六" | "七" | "八" | "九"
product ::= tens | teen | unit | "十"
unit ::= "零" | "一" | "二" | "三" | "四" | "五" | "六" | "七" | "八" | "九" | alias
alias ::= "" | "" | "" | "" | "" | ""
teen ::= "十" unit
tens ::= unit "十" unit?
ar ::= [0-9] [0-9]?

View File

@@ -5,94 +5,128 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>🦉 乘法口诀大闯关</title>
<style>
/* ================= 多邻国风 · PC/Pad 布局 =================
设计基调:高饱和主色 + 厚底 3D 按钮 + 大圆角卡片 + 弹跳动效
仅面向 PC / Pad≥760px不做手机端适配 */
:root{
--green:#58cc02; --green-d:#58a700; --red:#ff4b4b; --red-d:#d63535;
--blue:#1cb0f6; --blue-d:#1899d6; --yellow:#ffc800; --purple:#ce82ff;
--green:#58cc02; --green-d:#46a302; --red:#ff4b4b; --red-d:#d63535;
--blue:#1cb0f6; --blue-d:#1899d6; --yellow:#ffc800; --yellow-d:#d9a900;
--purple:#ce82ff; --purple-d:#a568d6;
--ink:#3c3c3c; --gray:#e5e5e5; --gray-t:#afafaf;
--bg:#f4f7fb; --card:#ffffff; --line:#e4e8ee;
--shadow-card:0 10px 28px rgba(60,80,120,.08);
}
*{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body{ height:100%; margin:0; }
body{
font-family:"PingFang SC","Microsoft YaHei",-apple-system,"Segoe UI",system-ui,sans-serif;
color:var(--ink); background:#fff;
min-height:100%; display:flex; align-items:flex-start; justify-content:center;
padding:16px; overflow-x:hidden; user-select:none;
font-family:"SF Pro Rounded","PingFang SC","Microsoft YaHei",-apple-system,"Segoe UI",system-ui,sans-serif;
color:var(--ink);
background:
radial-gradient(720px 460px at 6% -8%, rgba(88,204,2,.12), transparent 62%),
radial-gradient(820px 540px at 104% 12%, rgba(28,176,246,.12), transparent 62%),
radial-gradient(600px 420px at 50% 112%, rgba(255,200,0,.10), transparent 60%),
var(--bg);
min-height:100%; min-width:760px;
display:flex; align-items:center; justify-content:center;
padding:28px 32px; overflow-x:auto; user-select:none;
}
.app{ width:100%; max-width:480px; }
.card{ padding:12px 6px 40px; }
h1{ text-align:center; margin:10px 0 2px; font-size:28px; font-weight:900; color:var(--green); }
.subtitle{ text-align:center; margin:0 0 18px; color:var(--gray-t); font-size:15px; font-weight:600; }
.app{ width:100%; max-width:1040px; }
.card{ margin:0 auto; padding:12px 8px 44px; }
#home{ max-width:960px; }
#game,#end{ max-width:660px; }
#match{ max-width:1020px; }
h1{ text-align:center; margin:12px 0 4px; font-size:46px; font-weight:900; color:var(--green);
letter-spacing:2px; text-shadow:0 2px 0 rgba(70,163,2,.18); }
.subtitle{ text-align:center; margin:0 0 26px; color:var(--gray-t); font-size:19px; font-weight:700; }
.hide{ display:none !important; }
button:focus-visible, input:focus-visible{ outline:3px solid var(--blue); outline-offset:2px; }
.mascot{ font-size:78px; text-align:center; line-height:1; margin:6px 0; }
@keyframes bob{ 0%,100%{transform:translateY(0) rotate(-3deg);} 50%{transform:translateY(-12px) rotate(3deg);} }
.mascot{ font-size:88px; text-align:center; line-height:1; margin:8px 0;
filter:drop-shadow(0 8px 14px rgba(60,80,120,.18)); }
@keyframes bob{ 0%,100%{transform:translateY(0) rotate(-3deg);} 50%{transform:translateY(-14px) rotate(3deg);} }
.bob{ animation:bob 2.4s ease-in-out infinite; }
@keyframes pop{ 0%{transform:scale(.5);} 65%{transform:scale(1.28);} 100%{transform:scale(1);} }
.pop{ animation:pop .45s ease-out; }
@keyframes shake{ 0%,100%{transform:translateX(0);} 20%{transform:translateX(-11px);} 40%{transform:translateX(11px);} 60%{transform:translateX(-8px);} 80%{transform:translateX(8px);} }
.shake{ animation:shake .45s; }
@keyframes rise{ 0%{opacity:0; transform:translateY(26px) scale(.96);} 100%{opacity:1; transform:none;} }
.btn{
border:none; border-radius:16px; font-family:inherit; font-weight:800; letter-spacing:.5px;
border:none; border-radius:18px; font-family:inherit; font-weight:800; letter-spacing:.5px;
cursor:pointer; padding:16px; font-size:19px; color:#fff;
box-shadow:0 5px 0 var(--green-d); transition:transform .06s, box-shadow .06s;
box-shadow:0 5px 0 var(--green-d); transition:transform .08s, box-shadow .08s, filter .12s;
width:100%; margin:10px 0; display:flex; align-items:center; justify-content:center; gap:10px;
}
.btn:active{ transform:translateY(4px); box-shadow:0 1px 0 var(--green-d); }
.btn.big{ font-size:22px; padding:20px; }
@media(hover:hover){ .btn:hover{ filter:brightness(1.06); transform:translateY(-2px); } }
.btn:active{ transform:translateY(4px); box-shadow:0 1px 0 var(--green-d); filter:none; }
.btn.big{ font-size:23px; padding:20px; }
.b-green{background:var(--green); box-shadow:0 5px 0 var(--green-d);}
.b-blue{background:var(--blue); box-shadow:0 5px 0 var(--blue-d);}
.b-red{background:var(--red); box-shadow:0 5px 0 var(--red-d);}
.b-yellow{background:var(--yellow); color:#7a5b00; box-shadow:0 5px 0 #d9a900;}
.b-purple{background:var(--purple); box-shadow:0 5px 0 #a568d6;}
.b-yellow{background:var(--yellow); color:#7a5b00; box-shadow:0 5px 0 var(--yellow-d);}
.b-purple{background:var(--purple); box-shadow:0 5px 0 var(--purple-d);}
.b-white{background:#fff; color:var(--gray-t); border:2px solid var(--gray); box-shadow:0 3px 0 var(--gray);}
.btn.small{ font-size:15px; padding:11px 14px; width:auto; margin:0; border-radius:14px; }
.btn.small{ font-size:16px; padding:12px 18px; width:auto; margin:0; border-radius:15px; }
.top{ display:flex; align-items:center; gap:12px; margin:2px 0 16px; }
.exit{ background:none; border:none; color:var(--gray-t); font-size:26px; font-weight:900; cursor:pointer; line-height:1; padding:0 4px; }
.bar{ flex:1; height:16px; background:var(--gray); border-radius:999px; overflow:hidden; }
.bar>i{ display:block; height:100%; width:0; background:var(--green); border-radius:999px; transition:width .4s; }
.scoretag{ font-weight:900; color:var(--yellow); font-size:17px; white-space:nowrap; }
/* 首页:三张模式卡横排,错峰入场 */
.modes{ display:grid; grid-template-columns:repeat(3,1fr); gap:20px; margin:6px 0 4px; }
.entry{ flex-direction:column; gap:8px; padding:30px 20px 26px; margin:0; border-radius:24px;
animation:rise .55s cubic-bezier(.2,.9,.3,1.2) backwards; }
.modes .entry:nth-child(1){ animation-delay:.05s; }
.modes .entry:nth-child(2){ animation-delay:.15s; }
.modes .entry:nth-child(3){ animation-delay:.25s; }
@media(hover:hover){ .entry:hover{ transform:translateY(-6px) rotate(-.6deg); } }
.entry .e-emoji{ font-size:52px; line-height:1; filter:drop-shadow(0 4px 6px rgba(0,0,0,.15)); }
.entry .e-title{ font-size:26px; font-weight:900; }
.entry .e-sub{ font-size:14px; font-weight:700; opacity:.92; }
.timer{ height:12px; background:var(--gray); border-radius:999px; overflow:hidden; margin:4px 0 18px; }
.top{ display:flex; align-items:center; gap:14px; margin:2px 0 18px; }
.exit{ background:none; border:none; color:var(--gray-t); font-size:28px; font-weight:900; cursor:pointer;
line-height:1; padding:2px 6px; border-radius:10px; transition:transform .12s, color .12s; }
@media(hover:hover){ .exit:hover{ color:var(--red); transform:scale(1.15); } }
.bar{ flex:1; height:18px; background:var(--gray); border-radius:999px; overflow:hidden; }
.bar>i{ display:block; height:100%; width:0; background:linear-gradient(180deg,#6fdd1a,var(--green)); border-radius:999px; transition:width .4s; }
.scoretag{ font-weight:900; color:var(--yellow-d); font-size:20px; white-space:nowrap;
background:rgba(255,200,0,.16); border-radius:999px; padding:4px 14px; }
.timer{ height:14px; background:var(--gray); border-radius:999px; overflow:hidden; margin:4px 0 20px; }
.timer>i{ display:block; height:100%; width:100%; background:var(--green); border-radius:999px; }
.problem{ text-align:center; font-size:62px; font-weight:900; margin:14px 0 6px; letter-spacing:2px; }
.problem{ text-align:center; font-size:84px; font-weight:900; margin:16px 0 6px; letter-spacing:3px; }
.problem .q{ color:var(--blue); }
.kou-hint{ text-align:center; font-size:20px; color:var(--gray-t); min-height:26px; margin-bottom:8px; font-weight:800; }
.kou-hint{ text-align:center; font-size:24px; color:var(--gray-t); min-height:30px; margin-bottom:8px; font-weight:800; }
.bubble{
background:#f7f7f7; border:2px solid var(--gray); border-radius:16px; padding:14px 16px; text-align:center;
font-size:22px; font-weight:800; min-height:56px; margin:10px 0; display:flex; align-items:center; justify-content:center;
color:var(--ink);
background:var(--card); border:2px solid var(--line); border-radius:18px; padding:16px 20px; text-align:center;
font-size:24px; font-weight:800; min-height:64px; margin:12px 0; display:flex; align-items:center; justify-content:center;
color:var(--ink); box-shadow:var(--shadow-card);
}
.bubble .dim{ color:var(--gray-t); font-weight:600; font-size:17px; }
.bubble .dim{ color:var(--gray-t); font-weight:600; font-size:18px; }
.mic-wrap{ text-align:center; margin:6px 0; }
.mic{ width:104px; height:104px; border-radius:50%; border:none; cursor:pointer; font-size:48px; color:#fff;
background:var(--green); box-shadow:0 6px 0 var(--green-d); }
.mic-wrap{ text-align:center; margin:10px 0; }
.mic{ width:118px; height:118px; border-radius:50%; border:none; cursor:pointer; font-size:54px; color:#fff;
background:var(--green); box-shadow:0 7px 0 var(--green-d); transition:transform .08s, filter .12s; }
@media(hover:hover){ .mic:hover{ filter:brightness(1.06); } }
.mic:active{ transform:translateY(3px); box-shadow:0 3px 0 var(--green-d); }
@keyframes ripple{ 0%{box-shadow:0 6px 0 var(--blue-d), 0 0 0 0 rgba(28,176,246,.5);} 100%{box-shadow:0 6px 0 var(--blue-d), 0 0 0 30px rgba(28,176,246,0);} }
@keyframes ripple{ 0%{box-shadow:0 7px 0 var(--blue-d), 0 0 0 0 rgba(28,176,246,.5);} 100%{box-shadow:0 7px 0 var(--blue-d), 0 0 0 36px rgba(28,176,246,0);} }
.mic.listening{ animation:ripple 1.1s infinite; background:var(--blue); }
@keyframes spin{ to{transform:rotate(360deg);} }
.mic.think{ background:var(--purple); box-shadow:0 6px 0 #a568d6; }
.mic-label{ font-size:15px; color:var(--gray-t); font-weight:800; margin-top:8px; }
.mic.think{ background:var(--purple); box-shadow:0 7px 0 var(--purple-d); }
.mic-label{ font-size:16px; color:var(--gray-t); font-weight:800; margin-top:10px; }
.pad{ display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin-top:8px; }
.pad button{ font-family:inherit; font-size:26px; font-weight:800; padding:16px 0; border:2px solid var(--gray);
border-radius:14px; background:#fff; color:var(--ink); cursor:pointer; box-shadow:0 3px 0 var(--gray); }
.pad{ display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:8px; }
.pad button{ font-family:inherit; font-size:28px; font-weight:800; padding:18px 0; border:2px solid var(--gray);
border-radius:16px; background:#fff; color:var(--ink); cursor:pointer; box-shadow:0 3px 0 var(--gray);
transition:transform .06s, filter .1s; }
@media(hover:hover){ .pad button:hover{ filter:brightness(.97); } }
.pad button:active{ transform:translateY(3px); box-shadow:none; }
.pad .go{ background:var(--green); color:#fff; border-color:var(--green-d); box-shadow:0 3px 0 var(--green-d); }
.pad .del{ background:var(--yellow); color:#7a5b00; border-color:#d9a900; box-shadow:0 3px 0 #d9a900; }
.pad .del{ background:var(--yellow); color:#7a5b00; border-color:var(--yellow-d); box-shadow:0 3px 0 var(--yellow-d); }
.tools{ display:flex; gap:8px; margin-top:16px; justify-content:center; flex-wrap:wrap; }
.tools{ display:flex; gap:10px; margin-top:18px; justify-content:center; flex-wrap:wrap; }
.settings{ margin-top:18px; border:2px solid var(--gray); border-radius:16px; overflow:hidden; }
.settings summary{ cursor:pointer; padding:12px 16px; font-weight:800; color:var(--gray-t); list-style:none; }
.settings summary::-webkit-details-marker{ display:none; }
.settings .body{ padding:6px 16px 16px; }
.set-row{ display:flex; align-items:center; justify-content:space-between; padding:8px 0; font-weight:700; font-size:15px; color:#555; }
.set-row input[type=number]{ width:64px; font:inherit; font-weight:800; text-align:center; border:2px solid var(--gray); border-radius:10px; padding:6px; }
.set-row{ display:flex; align-items:center; justify-content:space-between; padding:10px 0; font-weight:700; font-size:16px; color:#555; }
.set-row input[type=number]{ width:66px; font:inherit; font-weight:800; text-align:center; border:2px solid var(--gray); border-radius:10px; padding:6px; }
.range-pair{ display:flex; align-items:center; gap:8px; font-weight:800; color:var(--gray-t); }
.switch{ position:relative; width:52px; height:30px; }
.switch input{ display:none; }
.switch i{ position:absolute; inset:0; background:var(--gray); border-radius:999px; transition:.2s; }
@@ -101,38 +135,35 @@
.switch input:checked + i::after{ transform:translateX(22px); }
/* 角落齿轮按钮 */
.gear{ position:absolute; top:2px; right:2px; width:44px; height:44px; border:none; background:none;
font-size:23px; color:var(--gray-t); cursor:pointer; opacity:.55; transition:transform .2s, opacity .2s; }
.gear:hover{ opacity:1; } .gear:active{ transform:rotate(45deg); }
/* 两个大入口 */
.entry{ flex-direction:column; gap:3px; padding:22px 18px; }
.entry .e-title{ font-size:24px; font-weight:900; }
.entry .e-sub{ font-size:13px; font-weight:700; opacity:.92; }
.gear{ position:absolute; top:4px; right:4px; width:48px; height:48px; border:none; background:none;
font-size:26px; color:var(--gray-t); cursor:pointer; opacity:.55; transition:transform .2s, opacity .2s; }
.gear:hover{ opacity:1; transform:rotate(30deg); } .gear:active{ transform:rotate(60deg); }
/* 设置弹窗 */
.modal{ position:fixed; inset:0; background:rgba(0,0,0,.38); z-index:70;
.modal{ position:fixed; inset:0; background:rgba(24,40,60,.4); z-index:70;
display:flex; align-items:center; justify-content:center; padding:20px; }
.modal-card{ background:#fff; width:100%; max-width:420px; border-radius:22px; padding:18px 20px;
box-shadow:0 20px 60px rgba(0,0,0,.28); animation:mpop .22s ease-out; }
.modal-card{ background:#fff; width:100%; max-width:460px; border-radius:24px; padding:20px 24px;
box-shadow:0 24px 70px rgba(0,0,0,.3); animation:mpop .22s ease-out; }
@keyframes mpop{ 0%{transform:scale(.9); opacity:0;} 100%{transform:scale(1); opacity:1;} }
.modal-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:6px;
font-weight:900; font-size:19px; color:var(--ink); }
font-weight:900; font-size:20px; color:var(--ink); }
.modal-x{ background:none; border:none; font-size:22px; font-weight:900; color:var(--gray-t); cursor:pointer; padding:4px 8px; }
/* 底部结果条(多邻国式反馈条),内容在宽屏下限宽居中 */
.sheet{ position:fixed; left:0; right:0; bottom:0; transform:translateY(120%);
transition:transform .3s cubic-bezier(.2,.9,.3,1.15); z-index:60;
padding:22px 20px calc(22px + env(safe-area-inset-bottom));
border-top-left-radius:22px; border-top-right-radius:22px; box-shadow:0 -6px 24px rgba(0,0,0,.08); }
padding:24px 32px calc(24px + env(safe-area-inset-bottom));
border-top-left-radius:26px; border-top-right-radius:26px; box-shadow:0 -8px 30px rgba(0,0,0,.1); }
.sheet.show{ transform:translateY(0); }
.sheet.ok{ background:#d7ffb8; } .sheet.no{ background:#ffdfe0; }
.sheet .head{ display:flex; align-items:center; gap:14px; }
.sheet .icon{ font-size:44px; }
.sheet .s-title{ font-size:24px; font-weight:900; }
.sheet .head{ display:flex; align-items:center; gap:16px; max-width:720px; margin:0 auto; }
.sheet .icon{ font-size:50px; }
.sheet .s-title{ font-size:27px; font-weight:900; }
.sheet.ok .s-title{ color:var(--green-d); } .sheet.no .s-title{ color:#ea2b2b; }
.sheet .s-sub{ font-size:16px; font-weight:800; margin-top:2px; }
.sheet .s-sub{ font-size:18px; font-weight:800; margin-top:2px; }
.sheet.ok .s-sub{ color:var(--green-d); } .sheet.no .s-sub{ color:#ea2b2b; }
.sheet .cont{ margin-top:16px; width:100%; border:none; border-radius:16px; padding:15px; font-size:18px; font-weight:900; color:#fff; cursor:pointer; }
.sheet .cont{ margin:18px auto 0; display:block; width:100%; max-width:720px; border:none; border-radius:18px;
padding:16px; font-size:19px; font-weight:900; color:#fff; cursor:pointer; transition:transform .08s; }
.sheet.ok .cont{ background:var(--green); box-shadow:0 5px 0 var(--green-d); }
.sheet.no .cont{ background:var(--red); box-shadow:0 5px 0 var(--red-d); }
.sheet .cont:active{ transform:translateY(4px); }
@@ -145,9 +176,49 @@
.fx span{ position:absolute; animation:fall 1.5s ease-in forwards; }
@keyframes fall{ 0%{transform:translateY(-12vh) rotate(0); opacity:1;} 100%{transform:translateY(112vh) rotate(560deg); opacity:0;} }
.stars{ font-size:44px; text-align:center; letter-spacing:6px; margin:10px 0; }
.score-big{ font-size:52px; text-align:center; font-weight:900; color:var(--yellow); }
.note{ font-size:12.5px; color:var(--gray-t); text-align:center; margin-top:16px; line-height:1.7; font-weight:600; }
.stars{ font-size:52px; text-align:center; letter-spacing:8px; margin:12px 0; }
.score-big{ font-size:60px; text-align:center; font-weight:900; color:var(--yellow-d); }
.note{ font-size:13.5px; color:var(--gray-t); text-align:center; margin-top:16px; line-height:1.8; font-weight:600; }
/* ===== 抢答模式:左右玩家 + 中间出题区(宽屏三栏) ===== */
.qnum{ flex:1; text-align:center; font-weight:900; color:var(--gray-t); font-size:18px; }
.arena{ display:flex; gap:22px; align-items:stretch; }
.player{ width:172px; flex:none; background:var(--card); border:3px solid var(--line); border-radius:24px;
padding:22px 10px 16px; text-align:center; box-shadow:var(--shadow-card);
display:flex; flex-direction:column; align-items:center; gap:7px; position:relative;
transition:border-color .15s, box-shadow .15s, transform .15s;
animation:rise .5s cubic-bezier(.2,.9,.3,1.2) backwards; }
.arena .player:last-child{ animation-delay:.1s; }
.player .avatar{ font-size:62px; line-height:1.15; filter:drop-shadow(0 4px 6px rgba(0,0,0,.12)); }
.player .pname{ font-size:17px; font-weight:900; color:#555; }
.player .pscore{ font-size:40px; font-weight:900; color:var(--yellow-d); line-height:1.1; }
.player .pkey{ font-size:13px; font-weight:700; color:var(--gray-t); word-break:break-all; min-height:17px; }
.player .bindbtn{ font-family:inherit; font-size:14px; font-weight:800; border:2px solid var(--gray); background:#fff; color:#777;
border-radius:12px; padding:8px 12px; cursor:pointer; margin-top:2px; box-shadow:0 2px 0 var(--gray); transition:filter .1s; }
@media(hover:hover){ .player .bindbtn:hover{ filter:brightness(.96); } }
.player .bindbtn.capturing{ border-color:var(--blue); color:var(--blue); animation:blink .8s infinite; }
@keyframes blink{ 50%{opacity:.35;} }
.player.buzzed{ border-color:var(--yellow); transform:scale(1.08);
box-shadow:0 0 0 5px rgba(255,200,0,.35), 0 10px 26px rgba(255,200,0,.45); }
.player.winner{ border-color:var(--yellow); box-shadow:0 0 0 5px rgba(255,200,0,.4), var(--shadow-card); }
.player.loser{ opacity:.45; }
.buzztag{ position:absolute; top:-15px; left:50%; transform:translateX(-50%); background:var(--yellow); color:#7a5b00;
font-size:14px; font-weight:900; border-radius:999px; padding:4px 12px; white-space:nowrap;
box-shadow:0 3px 0 var(--yellow-d); animation:pop .4s ease-out; z-index:2; }
.mid{ flex:1; min-width:0; display:flex; flex-direction:column; }
#match .problem{ font-size:64px; margin:8px 0 2px; }
#match .bubble{ font-size:21px; min-height:56px; padding:12px 16px; }
#match .mic{ width:96px; height:96px; font-size:44px; }
#match .mascot{ font-size:58px; }
/* 抢答大厅:顺序/随机 分段选择 */
.segwrap{ display:flex; gap:10px; justify-content:center; margin:6px 0 4px; }
.seg{ font-family:inherit; font-size:17px; font-weight:800; padding:12px 22px; cursor:pointer;
border:2px solid var(--gray); border-radius:16px; background:#fff; color:var(--gray-t);
box-shadow:0 3px 0 var(--gray); transition:all .12s; }
.seg.on{ background:var(--blue); border-color:var(--blue-d); color:#fff; box-shadow:0 3px 0 var(--blue-d); }
@media(hover:hover){ .seg:hover{ filter:brightness(1.03); transform:translateY(-1px); } }
.seg:active{ transform:translateY(2px); box-shadow:none; }
</style>
</head>
<body>
@@ -159,10 +230,14 @@
<div class="mascot bob">🦉</div>
<h1>乘法口诀大闯关</h1>
<p class="subtitle">开口念答案,全程不用手!</p>
<button class="btn b-green entry" onclick="startGame('seq')">
<span class="e-title">🐢 顺序闯关</span><span class="e-sub">1×1 到 9×9 按顺序背</span></button>
<button class="btn b-blue entry" onclick="startGame('rand')">
<span class="e-title">🎲 随机挑战</span><span class="e-sub">打乱抽题,错题多练</span></button>
<div class="modes">
<button class="btn b-green entry" onclick="startGame('seq')">
<span class="e-emoji">🐢</span><span class="e-title">顺序闯关</span><span class="e-sub">按口诀表顺序背</span></button>
<button class="btn b-blue entry" onclick="startGame('rand')">
<span class="e-emoji">🎲</span><span class="e-title">随机挑战</span><span class="e-sub">打乱抽题,错题多练</span></button>
<button class="btn b-purple entry" onclick="enterMatch()">
<span class="e-emoji">🆚</span><span class="e-title">双人抢答</span><span class="e-sub">按键抢答,比比谁快</span></button>
</div>
<p class="note" id="homeNote"></p>
</div>
@@ -170,6 +245,9 @@
<div class="modal hide" id="settingsModal" onclick="if(event.target===this)closeSettings()">
<div class="modal-card">
<div class="modal-head"><span>⚙️ 家长设置</span><button class="modal-x" onclick="closeSettings()"></button></div>
<div class="set-row"><span>📖 口诀范围(几开头)</span>
<span class="range-pair"><input type="number" id="cfgRangeMin" min="1" max="9" step="1" value="1" onchange="setRange('min',this.value)">
<input type="number" id="cfgRangeMax" min="1" max="9" step="1" value="9" onchange="setRange('max',this.value)"></span></div>
<div class="set-row"><span>🔊 自动读题</span>
<label class="switch"><input type="checkbox" id="cfgRead" checked onchange="CONFIG.ttsQuestion=this.checked"><i></i></label></div>
<div class="set-row"><span>⏱️ 限时作答</span>
@@ -180,6 +258,12 @@
<input type="number" id="cfgLen" min="5" max="99" step="1" value="20" oninput="CONFIG.sessionLength=Math.max(5,+this.value||20)"></div>
<div class="set-row"><span>「慢」判定秒数</span>
<input type="number" id="cfgSlow" min="1" max="20" step="0.5" value="3" oninput="CONFIG.slowMs=Math.max(0.5,+this.value||3)*1000"></div>
<div class="set-row"><span>抢答等待秒数</span>
<input type="number" id="cfgBuzz" min="2" max="30" step="1" value="5" oninput="CONFIG.matchBuzzMs=Math.max(2,+this.value||5)*1000"></div>
<div class="set-row"><span>抢到后作答秒数</span>
<input type="number" id="cfgAns" min="2" max="30" step="1" value="6" oninput="CONFIG.matchAnswerMs=Math.max(2,+this.value||6)*1000"></div>
<div class="set-row"><span>抢答一局题数</span>
<input type="number" id="cfgMatchLen" min="4" max="50" step="1" value="10" oninput="CONFIG.matchQuestions=Math.max(4,+this.value||10)"></div>
<p class="note" style="margin-top:6px">随机模式里答错或答慢的口诀会更常出现,练熟后自动减少。</p>
</div>
</div>
@@ -220,6 +304,58 @@
</div>
</div>
<!-- ===== 抢答页(左右玩家 + 中间出题区) ===== -->
<div id="match" class="card hide">
<div class="top">
<button class="exit" onclick="quit()" title="返回"></button>
<span class="qnum" id="mQnum">双人抢答</span>
<span style="width:30px"></span>
</div>
<div class="timer hide" id="mTimerWrap"><i id="mTimerBar"></i></div>
<div class="arena">
<div class="player" id="pL">
<span class="buzztag hide" id="tagL">⚡ 抢到了!</span>
<div class="avatar">🐯</div><div class="pname">左边</div>
<div class="pscore" id="scoreL">0</div>
<div class="pkey" id="keyL"></div>
<button class="bindbtn" id="bindL" onclick="bindCapture('L')">🔧 绑定按键</button>
</div>
<div class="mid">
<div class="mascot" id="mMascot" style="font-size:52px">🦉</div>
<div class="problem" id="mProblem"></div>
<div class="kou-hint" id="mKouHint"></div>
<div class="bubble" id="mHeard"><span class="dim">准备好啦~</span></div>
<div class="mic-wrap hide" id="mMicWrap">
<button class="mic" id="mMic" onclick="micTap()">🎤</button>
<div class="mic-label" id="mMicLabel">快说答案!</div>
</div>
<div id="mPadSlot"></div>
<div id="mLobby">
<div class="segwrap">
<button class="seg" id="mOrderSeq" onclick="setMatchOrder('seq')">🐢 顺序出题</button>
<button class="seg on" id="mOrderRand" onclick="setMatchOrder('rand')">🎲 随机出题</button>
</div>
<button class="btn b-green big" onclick="matchStart()">🚀 开始比赛</button>
<p class="note" style="margin-top:6px">出题后按自己绑定的键抢答,抢到的小朋友开口说答案。<br>答对 +5 分,答错或超时 2 分。支持键盘和蓝牙手柄按键。</p>
</div>
<div id="mEnd" class="hide">
<div class="mascot" id="mCrown">👑</div>
<div class="score-big" id="mEndScore"></div>
<p class="subtitle" id="mEndMsg"></p>
<button class="btn b-green" onclick="matchStart()">🔁 再来一局</button>
<button class="btn b-white" onclick="quit()">🏠 回首页</button>
</div>
</div>
<div class="player" id="pR">
<span class="buzztag hide" id="tagR">⚡ 抢到了!</span>
<div class="avatar">🐰</div><div class="pname">右边</div>
<div class="pscore" id="scoreR">0</div>
<div class="pkey" id="keyR"></div>
<button class="bindbtn" id="bindR" onclick="bindCapture('R')">🔧 绑定按键</button>
</div>
</div>
</div>
<!-- ===== 结算页 ===== -->
<div id="end" class="card hide">
<div class="mascot bob" id="endMascot">🏆</div>
@@ -246,6 +382,7 @@
const CONFIG = {
ttsQuestion:true, // 自动朗读题目
readRate:0.6, // 读题语速(1=正常,越小越慢)
tableMin:1, tableMax:9, // 口诀范围:只出「几开头」到「几开头」的口诀(全局,三种模式都生效)
timerEnabled:true, // 限时作答
timeLimitMs:6000, // 每题作答窗口(读题后开始计时/录音)
sessionLength:20, // 随机模式一局题数
@@ -256,10 +393,16 @@ const CONFIG = {
seqTeachAfter:1, // 顺序模式错几次后自动念口诀
// 录音 / 静音检测(VAD)
vadThreshold:0.018, // 音量阈值(0-1),超过算“在说话”
vadStartMs:150, // 连续多久有声音算“开口了”
vadStartMs:100, // 连续多久有声音算“开口了”(太大会漏掉“一”这类短促单音节)
vadSilenceMs:1100, // 开口后静音多久算“说完了”(放宽以容忍背口诀“三七…二十一”中间的停顿)
maxAttempts:3, // 一题内没听清最多自动重录几次
noMicWindowMs:8000, // 关闭限时时的最长录音窗口
// 抢答模式
matchBuzzMs:5000, // 出题后多久内可以抢答
matchAnswerMs:6000, // 抢到后必须在几秒内作答A 秒)
matchQuestions:10, // 一局题数
matchWinPts:5, // 答对得分
matchLosePts:2, // 答错/超时扣分
};
/* ============ 口诀数据 ============ */
@@ -270,11 +413,14 @@ function kouOf(a,b){ const p=a*b; return CN[a]+CN[b]+(p<10?'得'+CN[p]:numToCn(p
// 读题(口诀填空式):个位数答案读“一三得”,两位数答案读“三七”,把得数留给小朋友接
function kouPrompt(a,b){ const p=a*b; return CN[a]+CN[b]+(p<10?'得':''); }
function buildProblems(){ const list=[];
for(let a=1;a<=9;a++) for(let b=a;b<=9;b++) list.push({a,b,p:a*b,kou:kouOf(a,b),weight:CONFIG.minWeight});
const lo=Math.min(CONFIG.tableMin,CONFIG.tableMax), hi=Math.max(CONFIG.tableMin,CONFIG.tableMax);
for(let a=lo;a<=hi;a++) for(let b=a;b<=9;b++) list.push({a,b,p:a*b,kou:kouOf(a,b),weight:CONFIG.minWeight});
return list; }
/* ============ 中文/数字解析 ============ */
const DIG = {:0,:0,:1,:1,:2,:2,:2,:3,:4,:5,:6,:7,:8,:9};
const DIG = {:0,:0,:1,:1,:2,:2,:2,:3,:4,:5,:6,:7,:8,:9,
// 发音兼容别名(与后端 grammar 的 alias 规则配套n/l 不分、同音变调 → 映射回数字
:6,:6,:6,:6,:9,:9};
function parseNumToken(s){
if(/^[0-9]+$/.test(s)) return parseInt(s,10);
if(s.includes('十')){ const i=s.indexOf('十'); const a=s.slice(0,i), b=s.slice(i+1);
@@ -291,13 +437,18 @@ function extractLastNumber(text){
return nums.length? nums[nums.length-1] : null;
}
function hasNumber(t){ return extractLastNumber(t)!==null; }
function judge(transcript,a,b,p){
if(!transcript) return null;
let t=transcript.replace(/[\s.,,。!!?、~~·=]/g,'')
// 剥掉标点/口语填充词/运算词,再剥掉句首因数前缀(正反序、中/数字),只留“得数”部分
function stripKou(transcript,a,b){
let t=(transcript||'').replace(/[\s.,,。!!?、~~·=]/g,'')
.replace(/得到|得|等于|结果|答案|应该|就是|是|的|啦|了|嘛|呀|哦|噢|嗯|呃|那|个/g,'')
.replace(/乘以|乘上|乘|×|✕|X|x|\*/g,'');
const prefixes=[CN[a]+CN[b], ''+a+b, CN[b]+CN[a], ''+b+a];
for(const pre of prefixes){ if(pre && t.startsWith(pre)){ t=t.slice(pre.length); break; } }
return t;
}
function judge(transcript,a,b,p){
if(!transcript) return null;
const t=stripKou(transcript,a,b);
if(parseNumToken(t)===p) return true;
if(extractLastNumber(t)===p) return true;
if(extractLastNumber(transcript)===p) return true;
@@ -314,6 +465,20 @@ function isJustFactors(transcript,a,b){
const n=extractLastNumber(t);
return n!==null && n!==a*b && (n===a*10+b || n===b*10+a || n===a || n===b);
}
// 疑似“十”被吞:得数是十几、只听到个位(如 2×7=14 只识别出“四”)→ 不判错,重录一次
function isTeenDroppedTen(transcript,a,b,p){
if(p<10||p>19) return false;
const n=extractLastNumber(stripKou(transcript,a,b));
return n===p%10;
}
// 疑似平翘舌/语序混淆:得数十几却听成 X0十四→四十或整十却听成十X三十→十三→ 不判错,重录一次
function isSwapConfusion(transcript,a,b,p){
const n=extractLastNumber(stripKou(transcript,a,b));
if(n===null) return false;
if(p>=11&&p<=19 && n===(p%10)*10) return true;
if(p%10===0&&p>=10&&p<=90 && n===10+p/10) return true;
return false;
}
/* ============ 语音播放TTS ============ */
let zhVoice=null;
@@ -334,7 +499,7 @@ function speakAsync(text,cancelFirst){
/* ============ 录音 + 静音检测 + 后端识别 ============ */
let mediaStream=null, audioCtx=null, analyser=null, vadBuf=null;
let mediaRecorder=null, recChunks=[], recording=false, recStart=0,
let mediaRecorder=null, recChunks=[], recording=false, recStart=0, recWindow=0,
speechStarted=false, lastVoiceAt=0, voiceRun=0, vadRAF=null, attempts=0;
const canRecord = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
@@ -359,7 +524,15 @@ function pickMime(){
for(const o of opts){ try{ if(MediaRecorder.isTypeSupported(o)) return o; }catch(e){} }
return '';
}
function autoWindowMs(){ return CONFIG.timerEnabled ? CONFIG.timeLimitMs : CONFIG.noMicWindowMs; }
function autoWindowMs(){
// 抢答模式A 秒是从“抢到”那一刻起的硬截止,重录只能用剩余时间,不重置窗口
if(matchMode) return Math.max(800, buzzAt ? buzzAt+CONFIG.matchAnswerMs-Date.now() : CONFIG.matchAnswerMs);
return CONFIG.timerEnabled ? CONFIG.timeLimitMs : CONFIG.noMicWindowMs;
}
// 抢答作答是否已到硬截止(留 150ms 余量,避免边界上再重录一轮)
function matchOvertime(){ return matchMode && buzzAt && (Date.now()-buzzAt) >= CONFIG.matchAnswerMs-150; }
// 所有作答路径的路由:抢答模式走 matchResolve单人模式走 resolve
function settle(correct,timedOut){ matchMode ? matchResolve(correct,timedOut) : resolve(correct,timedOut); }
function startRecordCycle(){
if(!mediaStream || answered) return;
@@ -371,7 +544,7 @@ function startRecordCycle(){
mediaRecorder.onstop=onRecStop;
recording=true;
try{ mediaRecorder.start(); }catch(e){ recording=false; switchToPad(); return; }
recStart=Date.now();
recStart=Date.now(); recWindow=autoWindowMs(); // 窗口在开录时定死,录音中不再变
micBtn.className='mic listening'; micLabel.textContent='我在听… 说答案!';
heard.innerHTML='<span class="dim">🎙️ 请说答案…</span>';
startTimer();
@@ -381,11 +554,11 @@ function vadTick(){
if(!recording) return;
let rms=0;
if(analyser){ analyser.getFloatTimeDomainData(vadBuf); let s=0; for(let i=0;i<vadBuf.length;i++) s+=vadBuf[i]*vadBuf[i]; rms=Math.sqrt(s/vadBuf.length); }
const now=Date.now(), win=autoWindowMs();
const now=Date.now();
if(rms>CONFIG.vadThreshold){ voiceRun+=16; if(voiceRun>=CONFIG.vadStartMs) speechStarted=true; lastVoiceAt=now; }
else { voiceRun=0; }
if(speechStarted && lastVoiceAt && (now-lastVoiceAt)>=CONFIG.vadSilenceMs){ stopRecord(); return; } // 说完了
if((now-recStart)>=win){ stopRecord(); return; } // 到时间
if((now-recStart)>=recWindow){ stopRecord(); return; } // 到时间
vadRAF=requestAnimationFrame(vadTick);
}
function stopRecord(){
@@ -400,75 +573,105 @@ async function onRecStop(){
const blob=new Blob(recChunks,{type});
if(!speechStarted || blob.size<1500){ // 根本没开口
attempts++;
if(attempts>=CONFIG.maxAttempts){ resolve(false,true); }
else { heard.innerHTML='<span class="dim">没听到声音,再说一次~</span>'; setTimeout(()=>{ if(!answered) startRecordCycle(); },250); }
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,true); } // 抢答A 秒硬截止,直接判超时
else { heard.innerHTML='<span class="dim">没听到声音,再说一次~</span>'; later(()=>{ if(!answered) startRecordCycle(); },250); }
return;
}
micBtn.className='mic think'; micLabel.textContent='识别中…'; heard.innerHTML='🧠 识别中…';
try{
const r=await fetch('/stt',{method:'POST',headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob});
const r=await fetch('/stt?a='+cur.a+'&b='+cur.b,{method:'POST',headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob});
const j=await r.json();
if(answered) return;
const text=(j.text||'').trim();
const verd=judge(text,cur.a,cur.b,cur.p);
heard.innerHTML = text? ('👂 '+text) : '<span class="dim">没听清</span>';
micBtn.className='mic'; micLabel.textContent='点我再说一次';
if(verd===true){ resolve(true,false); }
if(verd===true){ settle(true,false); }
else if(isJustFactors(text,cur.a,cur.b)){ // 只背了口诀前半(因数) → 当作没说完,继续听
attempts++;
heard.innerHTML='👂 '+text+' …';
if(attempts>=CONFIG.maxAttempts){ resolve(false,false); }
else setTimeout(()=>{ if(!answered) startRecordCycle(); },250);
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,matchOvertime()); }
else later(()=>{ if(!answered) startRecordCycle(); },250);
}
else if(text && hasNumber(text)){ resolve(false,false); } // 说了个数、但答错
else if(isTeenDroppedTen(text,cur.a,cur.b,cur.p) // 疑似“十”被吞(十几只听到个位)
|| isSwapConfusion(text,cur.a,cur.b,cur.p)){ // 或平翘舌/语序混淆(十四↔四十) → 不冤枉,再听一次
attempts++;
heard.innerHTML='👂 '+text+'?再说一次~';
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,matchOvertime()); }
else later(()=>{ if(!answered) startRecordCycle(); },250);
}
else if(text && hasNumber(text)){ settle(false,false); } // 说了个数、但答错
else { // 没识别出数字 → 再给机会
attempts++;
if(attempts>=CONFIG.maxAttempts){ resolve(false,false); }
else setTimeout(()=>{ if(!answered) startRecordCycle(); },300);
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,matchOvertime()); }
else later(()=>{ if(!answered) startRecordCycle(); },300);
}
}catch(e){
if(answered) return;
micBtn.className='mic'; heard.innerHTML='⚠️ 识别服务未连接';
attempts++;
if(attempts>=CONFIG.maxAttempts){ resolve(false,true); }
else setTimeout(()=>{ if(!answered) startRecordCycle(); },700);
if(attempts>=CONFIG.maxAttempts){ settle(false,true); }
else later(()=>{ if(!answered) startRecordCycle(); },700);
}
}
async function autoAsk(){
if(answered || !autoActive) return;
attempts=0;
if(CONFIG.ttsQuestion){ heard.innerHTML='<span class="dim">🔊 听题目…</span>'; await speakAsync(kouPrompt(cur.a,cur.b), true); }
if(answered || !autoActive) return;
startRecordCycle();
}
function micTap(){
if(!autoActive){ switchToPad(); return; }
if(recording){ stopRecord(); } // 说完了,立刻识别
else if(!answered){ autoAsk(); } // 再答一次
if(!autoActive){ if(!matchMode) switchToPad(); return; }
if(recording){ stopRecord(); } // 说完了,立刻识别
else if(!answered){ matchMode ? startRecordCycle() : autoAsk(); } // 再答一次(抢答模式不重读题)
}
/* ============ 计时器 ============ */
/* ============ 计时器rAF 逐帧驱动,丝滑不跳格) ============ */
let timerId=null, qStart=0;
function clearTimer(){ if(timerId){ clearInterval(timerId); timerId=null; } }
function clearTimer(){ if(timerId){ cancelAnimationFrame(timerId); timerId=null; } }
function startTimer(){
clearTimer(); qStart=Date.now();
if(!CONFIG.timerEnabled){ $('timerWrap').classList.add('hide'); return; }
$('timerWrap').classList.remove('hide');
const bar=$('timerBar'); bar.style.width='100%'; bar.style.background='var(--green)';
timerId=setInterval(()=>{
const el=Date.now()-qStart, left=Math.max(0,CONFIG.timeLimitMs-el), pct=left/CONFIG.timeLimitMs*100;
if(!matchMode && !CONFIG.timerEnabled){ timerWrapEl.classList.add('hide'); return; } // 抢答模式恒定限时
timerWrapEl.classList.remove('hide');
const bar=timerBarEl, win=autoWindowMs(); bar.style.width='100%'; bar.style.background='var(--green)';
const tick=()=>{
const el=Date.now()-qStart, left=Math.max(0,win-el), pct=left/win*100;
bar.style.width=pct+'%';
bar.style.background = pct<30?'var(--red)':(pct<60?'var(--yellow)':'var(--green)');
if(el>=CONFIG.timeLimitMs){ clearTimer(); if(!autoActive) resolve(false,true); } // 录音模式由 VAD 结束
},50);
if(el>=win){ timerId=null; if(!autoActive) settle(false,true); return; } // 录音模式由 VAD 结束
timerId=requestAnimationFrame(tick);
};
timerId=requestAnimationFrame(tick);
}
/* ============ 游戏状态 ============ */
let mode='seq', problems=[], idx=0, score=0, cur=null, padMode=false, answered=false,
answeredCount=0, missCount=0, lastKey='', pendingAdv=null, autoActive=false;
// 代际守卫:退出/换局时 epoch+1旧局排下的延迟回调下一题推进、自动重录等全部失效
// 防止“退出比赛后残留定时器把结果卡弹到首页 / 污染下一局”
let epoch=0;
function later(fn,ms){ const e=epoch; return setTimeout(()=>{ if(e===epoch) fn(); },ms); }
// 口诀范围持久化(全局设置,换端复用)
try{ const r=JSON.parse(localStorage.getItem('calx-range')||'null');
if(r && r.min>=1 && r.max<=9 && r.min<=r.max){ CONFIG.tableMin=r.min; CONFIG.tableMax=r.max; } }catch(e){}
function saveRange(){ try{ localStorage.setItem('calx-range',JSON.stringify({min:CONFIG.tableMin,max:CONFIG.tableMax})); }catch(e){} }
function setRange(which,v){
v=Math.min(9,Math.max(1,parseInt(v,10)||1));
if(which==='min'){ CONFIG.tableMin=v; if(CONFIG.tableMax<v) CONFIG.tableMax=v; }
else { CONFIG.tableMax=v; if(CONFIG.tableMin>v) CONFIG.tableMin=v; }
$('cfgRangeMin').value=CONFIG.tableMin; $('cfgRangeMax').value=CONFIG.tableMax; saveRange();
}
const $=id=>document.getElementById(id);
const home=$('home'),game=$('game'),end=$('end');
const micBtn=$('micBtn'),micLabel=$('micLabel'),heard=$('heard'),
problem=$('problem'),kouHint=$('kouHint'),gMascot=$('gMascot');
const problem=$('problem'),kouHint=$('kouHint'),gMascot=$('gMascot');
// 录音闭环的展示元素可重指向:单人模式指向 game 页,抢答模式指向 match 页中列
let micBtn=$('micBtn'),micLabel=$('micLabel'),heard=$('heard'),
timerWrapEl=$('timerWrap'),timerBarEl=$('timerBar');
function bindRecUI(m){
micBtn=$(m?'mMic':'micBtn'); micLabel=$(m?'mMicLabel':'micLabel'); heard=$(m?'mHeard':'heard');
timerWrapEl=$(m?'mTimerWrap':'timerWrap'); timerBarEl=$(m?'mTimerBar':'timerBar');
}
let seed=(Date.now()%100000)+1;
function rnd(){ seed=(seed*9301+49297)%233280; return seed/233280; }
@@ -484,7 +687,7 @@ function pickWeighted(){
}
async function startGame(m){
mode=m; score=0; idx=0; answeredCount=0; lastKey='';
epoch++; mode=m; score=0; idx=0; answeredCount=0; lastKey='';
problems=buildProblems(); $('scorePill').textContent='⭐0';
home.classList.add('hide'); end.classList.add('hide'); game.classList.remove('hide');
padMode=false;
@@ -512,7 +715,7 @@ function renderQuestion(){
kouHint.textContent=''; gMascot.textContent='🦉';
$('barFill').style.width=(progressDone()/totalTarget()*100)+'%';
padBuf=''; $('padShow').textContent='点数字输入答案'; $('padShow').className='dim';
if(autoActive){ heard.innerHTML='<span class="dim">准备好啦~</span>'; micBtn.className='mic'; setTimeout(autoAsk,250); }
if(autoActive){ heard.innerHTML='<span class="dim">准备好啦~</span>'; micBtn.className='mic'; later(autoAsk,250); }
else { heard.innerHTML='<span class="dim">点数字输入答案</span>'; if(CONFIG.ttsQuestion) speakAsync(kouPrompt(cur.a,cur.b),true); startTimer(); }
}
@@ -557,7 +760,7 @@ function afterSheet(type,title,sub,thenFn,delay){
$('sheetIcon').textContent = type==='ok'?'🎉':'💡';
$('sheetTitle').textContent=title; $('sheetSub').textContent=sub;
const go=()=>{ s.classList.remove('show'); thenFn&&thenFn(); };
clearTimeout(pendingAdv); pendingAdv=setTimeout(go,delay);
clearTimeout(pendingAdv); pendingAdv=later(go,delay);
$('contBtn').onclick=()=>{ clearTimeout(pendingAdv); go(); };
}
function positive(elapsed){
@@ -593,7 +796,7 @@ function switchToMic(){
}
function padKey(k){ if(padBuf.length<2){ padBuf+=k; $('padShow').textContent=padBuf; $('padShow').className=''; } }
function padDel(){ padBuf=padBuf.slice(0,-1); $('padShow').textContent=padBuf||'点数字输入答案'; if(!padBuf)$('padShow').className='dim'; }
function padGo(){ if(!padBuf||answered)return; const v=parseInt(padBuf,10); padBuf=''; resolve(v===cur.p,false); }
function padGo(){ if(!padBuf||answered)return; const v=parseInt(padBuf,10); padBuf=''; settle(v===cur.p,false); }
function finish(){
clearTimer(); if(recording) stopRecord(); $('barFill').style.width='100%';
@@ -607,9 +810,230 @@ function finish(){
}
function restart(){ startGame(mode); }
function quit(){
clearTimer(); if(recording) stopRecord(); clearTimeout(pendingAdv);
epoch++; answered=true; clearTimer(); if(recording) stopRecord(); clearTimeout(pendingAdv);
if(matchMode) exitMatch();
speechSynthesis&&speechSynthesis.cancel(); $('sheet').classList.remove('show');
game.classList.add('hide'); end.classList.add('hide'); home.classList.remove('hide');
game.classList.add('hide'); end.classList.add('hide'); $('match').classList.add('hide');
home.classList.remove('hide');
}
/* ============ 抢答模式(左右玩家按键抢答 → 抢到者语音作答) ============ */
let matchMode=false, mState='lobby', buzzer=null, buzzAt=0, mScores={L:0,R:0}, mQIdx=0,
buzzTimerId=null, padPollRAF=null, padPrev={}, capturing=null, matchOrder='rand';
function matchTotal(){ return matchOrder==='seq' ? problems.length : CONFIG.matchQuestions; }
function setMatchOrder(o){
matchOrder=o;
$('mOrderSeq').className='seg'+(o==='seq'?' on':''); $('mOrderRand').className='seg'+(o==='rand'?' on':'');
}
// 抢答键绑定:{type:'key',code}(键盘)或 {type:'pad',pad,button}(蓝牙手柄),存 localStorage
let buzzKeys={ L:{type:'key',code:'KeyA',label:'键盘 A'}, R:{type:'key',code:'KeyL',label:'键盘 L'} };
try{ const s=JSON.parse(localStorage.getItem('calx-buzz')||'null'); if(s&&s.L&&s.R) buzzKeys=s; }catch(e){}
function saveBuzzKeys(){ try{ localStorage.setItem('calx-buzz',JSON.stringify(buzzKeys)); }catch(e){} }
const sameBind=(x,y)=> x&&y&&x.type===y.type &&
(x.type==='key' ? x.code===y.code : (x.pad===y.pad&&x.button===y.button));
function renderBindings(){
$('keyL').textContent='🔑 '+buzzKeys.L.label; $('keyR').textContent='🔑 '+buzzKeys.R.label;
for(const side of ['L','R']){
const b=$('bind'+side);
b.textContent = capturing===side?'请按键…':'🔧 绑定按键';
b.className = 'bindbtn'+(capturing===side?' capturing':'');
}
}
function bindCapture(side){
if(mState!=='lobby'&&mState!=='end') return;
capturing = capturing===side? null : side;
renderBindings();
}
function setBinding(side,bind){
if(sameBind(buzzKeys[side==='L'?'R':'L'],bind)){
heard.innerHTML='<span class="dim">这个键已经绑给另一边啦,换一个~</span>';
capturing=null; renderBindings(); return;
}
buzzKeys[side]=bind; saveBuzzKeys(); capturing=null; renderBindings();
heard.innerHTML='<span class="dim">'+(side==='L'?'🐯 左边':'🐰 右边')+'绑定成功:'+bind.label+'</span>';
}
function matchKeydown(e){
if(!matchMode) return;
if(capturing){
if(e.code==='Escape'){ capturing=null; renderBindings(); return; }
e.preventDefault();
setBinding(capturing,{type:'key',code:e.code,label:'键盘 '+(e.key&&e.key.trim().length===1?e.key.toUpperCase():e.code)});
return;
}
if(mState!=='buzz') return;
if(buzzKeys.L.type==='key'&&e.code===buzzKeys.L.code){ e.preventDefault(); buzz('L'); }
else if(buzzKeys.R.type==='key'&&e.code===buzzKeys.R.code){ e.preventDefault(); buzz('R'); }
}
// 手柄按键轮询:既做绑定录入,也做抢答判定(按下沿触发)
function padPoll(){
if(!matchMode){ padPollRAF=null; return; }
const pads=(navigator.getGamepads&&navigator.getGamepads())||[];
for(let gi=0; gi<pads.length; gi++){
const gp=pads[gi]; if(!gp) continue;
for(let bi=0; bi<gp.buttons.length; bi++){
const k=gi+':'+bi, down=gp.buttons[bi].pressed;
if(down && !padPrev[k]){
const bind={type:'pad',pad:gi,button:bi,label:'手柄'+(gi+1)+'·键'+bi};
if(capturing) setBinding(capturing,bind);
else if(mState==='buzz'){
if(sameBind(buzzKeys.L,bind)) buzz('L');
else if(sameBind(buzzKeys.R,bind)) buzz('R');
}
}
padPrev[k]=down;
}
}
padPollRAF=requestAnimationFrame(padPoll);
}
async function enterMatch(){
epoch++; matchMode=true; mState='lobby'; buzzer=null; capturing=null; answered=true;
home.classList.add('hide'); end.classList.add('hide'); $('match').classList.remove('hide');
bindRecUI(true);
$('mPadSlot').appendChild($('padWrap')); $('padWrap').classList.add('hide');
$('mLobby').classList.remove('hide'); $('mEnd').classList.add('hide');
$('mMicWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
$('mProblem').innerHTML=''; $('mKouHint').textContent=''; $('mQnum').textContent='双人抢答';
$('mMascot').textContent='🦉';
['pL','pR'].forEach(id=>{ $(id).className='player'; });
$('tagL').classList.add('hide'); $('tagR').classList.add('hide');
mScores={L:0,R:0}; renderScores(); renderBindings();
document.addEventListener('keydown',matchKeydown);
padPrev={}; if(!padPollRAF) padPollRAF=requestAnimationFrame(padPoll);
const ok=await ensureAudio(); // 在入口手势内解锁音频/麦克风
autoActive=ok; padMode=!ok;
heard.innerHTML = ok
? '<span class="dim">先绑定两边的抢答键,然后开始!</span>'
: '<span class="dim">麦克风不可用:抢到后用数字键盘作答</span>';
}
function exitMatch(){
matchMode=false; mState='lobby'; capturing=null;
stopBuzzCountdown();
document.removeEventListener('keydown',matchKeydown);
if(padPollRAF){ cancelAnimationFrame(padPollRAF); padPollRAF=null; }
// 数字键盘归还单人游戏页
$('padWrap').classList.add('hide');
game.insertBefore($('padWrap'), game.querySelector('.tools'));
bindRecUI(false);
}
function renderScores(hl){
$('scoreL').textContent=mScores.L; $('scoreR').textContent=mScores.R;
if(hl){ const el=$(hl==='L'?'scoreL':'scoreR'); el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop'); }
}
function matchStart(){
epoch++; mScores={L:0,R:0}; mQIdx=0; renderScores();
problems=buildProblems(); lastKey=''; capturing=null; renderBindings();
$('bindL').classList.add('hide'); $('bindR').classList.add('hide'); // 比赛中不允许改绑定
$('mLobby').classList.add('hide'); $('mEnd').classList.add('hide');
['pL','pR'].forEach(id=>{ $(id).className='player'; });
matchNext();
}
async function matchNext(){
if(!matchMode) return;
if(mQIdx>=matchTotal()) return matchFinish();
answered=false; attempts=0; buzzer=null; buzzAt=0;
['pL','pR'].forEach(id=>$(id).classList.remove('buzzed','shake'));
$('tagL').classList.add('hide'); $('tagR').classList.add('hide');
$('mMicWrap').classList.add('hide'); $('padWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
let c;
if(matchOrder==='seq'){ c=problems[mQIdx]; } // 顺序:按口诀表顺序过一遍
else { // 随机:均匀抽题,避免连续重复
c=problems[Math.floor(rnd()*problems.length)];
for(let i=0;i<6 && (c.a+'x'+c.b)===lastKey && problems.length>1;i++) c=problems[Math.floor(rnd()*problems.length)];
}
cur=c; lastKey=c.a+'x'+c.b; mQIdx++;
$('mQnum').textContent='第 '+mQIdx+' / '+matchTotal()+' 题';
$('mMascot').textContent='🦉';
$('mProblem').innerHTML=`${cur.a} × ${cur.b} = <span class="q">?</span>`;
$('mProblem').classList.remove('pop'); void $('mProblem').offsetWidth; $('mProblem').classList.add('pop');
$('mKouHint').textContent='';
heard.innerHTML='<span class="dim">🔊 听题…可以抢答了!</span>';
mState='buzz'; // 读题期间就可以抢
if(CONFIG.ttsQuestion) await speakAsync(kouPrompt(cur.a,cur.b),true);
if(!matchMode || mState!=='buzz') return; // 读题时已被抢答或已退出
heard.innerHTML='⚡ 抢答!快按你的键!';
startBuzzCountdown();
}
function startBuzzCountdown(){
stopBuzzCountdown();
$('mTimerWrap').classList.remove('hide');
const bar=$('mTimerBar'); bar.style.width='100%'; bar.style.background='var(--yellow)';
const t0=Date.now();
const tick=()=>{
const left=Math.max(0,CONFIG.matchBuzzMs-(Date.now()-t0));
bar.style.width=(left/CONFIG.matchBuzzMs*100)+'%';
if(!left){ buzzTimerId=null; noBuzz(); return; }
buzzTimerId=requestAnimationFrame(tick);
};
buzzTimerId=requestAnimationFrame(tick);
}
function stopBuzzCountdown(){ if(buzzTimerId){ cancelAnimationFrame(buzzTimerId); buzzTimerId=null; } }
function noBuzz(){
if(mState!=='buzz') return;
mState='between'; answered=true;
$('mTimerWrap').classList.add('hide');
$('mMascot').textContent='😴';
heard.innerHTML='⏰ 没人抢答~'; $('mKouHint').textContent=cur.kou;
speak(cur.kou);
later(()=>{ if(matchMode) matchNext(); },1800);
}
function buzz(side){
if(mState!=='buzz') return;
mState='answer'; buzzer=side; buzzAt=Date.now();
stopBuzzCountdown(); if(window.speechSynthesis) speechSynthesis.cancel();
$(side==='L'?'pL':'pR').classList.add('buzzed');
$(side==='L'?'tagL':'tagR').classList.remove('hide');
beep(true); flash('g');
$('mMascot').textContent='😲';
heard.innerHTML=(side==='L'?'🐯 左边':'🐰 右边')+'抢到了!快说答案!';
if(autoActive){ $('mMicWrap').classList.remove('hide'); startRecordCycle(); }
else {
$('padWrap').classList.remove('hide');
padBuf=''; $('padShow').textContent='点数字输入答案'; $('padShow').className='dim';
startTimer();
}
}
function matchResolve(correct,timedOut){
if(answered) return; answered=true; mState='between';
clearTimer(); recording=false; if(vadRAF){ cancelAnimationFrame(vadRAF); vadRAF=null; }
try{ if(mediaRecorder && mediaRecorder.state==='recording') mediaRecorder.stop(); }catch(e){}
micBtn.className='mic';
$('mMicWrap').classList.add('hide'); $('padWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
const side=buzzer||'L', name= side==='L'?'🐯 左边':'🐰 右边';
$('mKouHint').textContent=cur.kou;
if(correct){
mScores[side]+=CONFIG.matchWinPts;
$('mMascot').textContent='🥳';
heard.innerHTML='🎉 '+name+'答对啦,+'+CONFIG.matchWinPts+' 分!';
beep(true); flash('g'); confetti(18);
} else {
mScores[side]-=CONFIG.matchLosePts;
$('mMascot').textContent='😮';
heard.innerHTML=(timedOut?'⏰ 超时了':'❌ 答错了')+''+name+' '+CONFIG.matchLosePts+' 分。正确是 '+cur.p;
beep(false); flash('r');
const pEl=$(side==='L'?'pL':'pR'); pEl.classList.remove('shake'); void pEl.offsetWidth; pEl.classList.add('shake');
}
renderScores(side); speak(cur.kou);
later(()=>{ if(matchMode) matchNext(); },2200);
}
function matchFinish(){
mState='end'; answered=true;
$('mProblem').innerHTML=''; $('mKouHint').textContent=''; $('mQnum').textContent='比赛结束';
$('mMicWrap').classList.add('hide'); $('padWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
$('tagL').classList.add('hide'); $('tagR').classList.add('hide');
['pL','pR'].forEach(id=>$(id).classList.remove('buzzed'));
const l=mScores.L, r=mScores.R;
let msg, crown='👑';
if(l>r){ msg='左边小朋友是冠军!'; $('pL').classList.add('winner'); $('pR').classList.add('loser'); }
else if(r>l){ msg='右边小朋友是冠军!'; $('pR').classList.add('winner'); $('pL').classList.add('loser'); }
else { msg='平局!两位都是小冠军!'; crown='🤝'; $('pL').classList.add('winner'); $('pR').classList.add('winner'); }
$('mCrown').textContent=crown; $('mEndScore').textContent=l+' : '+r; $('mEndMsg').textContent=msg;
$('mMascot').textContent='🏆';
heard.innerHTML='🏁 '+msg;
$('mEnd').classList.remove('hide');
$('bindL').classList.remove('hide'); $('bindR').classList.remove('hide');
renderBindings();
speak(msg); bigConfetti();
}
/* ============ 音效 ============ */
@@ -642,7 +1066,8 @@ function burst(n){ const fx=$('fx');
function openSettings(){ $('settingsModal').classList.remove('hide'); }
function closeSettings(){ $('settingsModal').classList.add('hide'); }
/* ============ 首页提示(一行) ============ */
/* ============ 启动初始化 ============ */
$('cfgRangeMin').value=CONFIG.tableMin; $('cfgRangeMax').value=CONFIG.tableMax; // 口诀范围回填localStorage
$('homeNote').innerHTML = canRecord
? '💡 点“开始”后允许麦克风即可全程免手'
: '💡 当前环境不支持录音,将用数字键盘作答';

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 : ''}`);