- 识别:按题动态 grammar(前缀锁定因数)、只输出中文数字、发音兼容别名(n/l 不分、四十↔十四混淆重录) - 新增双人抢答:键盘/蓝牙手柄绑定抢答、A 秒硬截止扣分、实时计分与冠军结算,支持顺序/随机出题 - 全局口诀范围设置(几开头~几开头,localStorage 持久化) - 修复退出比赛残留定时器弹结果卡的 bug(epoch 代际守卫);计时条改 rAF 逐帧驱动 - 新增 eval.js 端到端识别回归评测(135 例 100%) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.1 KiB
9.1 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
小学生乘法口诀(九九表)语音背诵小程序。核心交互:自动读题 → 小朋友开口说答案 → 本地离线语音识别判分,全程免手(hands-free)。三种模式:顺序闯关、随机挑战(随机模式对错题动态加权)、双人抢答(键盘/蓝牙手柄按键抢答,抢到者语音作答,抢答内部又分顺序/随机出题)。UI 只面向 PC / Pad(≥760px),不做手机端适配,风格参考多邻国(高饱和主色 + 厚底 3D 按钮 + 大圆角 + 弹跳动效)。
只有两份代码文件:index.html(前端单页,含全部 UI/逻辑)和 server.js(后端,零 npm 依赖)。没有构建步骤、没有 package.json、没有 node_modules、没有测试框架。
Commands
# 新电脑一键部署(幂等:装依赖 → 补模型 → 生成证书 → 启动,见 DEPLOY.md)
bash bootstrap.sh
# 启动(HTTP:8000 + HTTPS:8443,首次自动生成自签证书)
bash start.sh # 或 node server.js
# 换电脑 / IP 变了后重新生成自签名证书
bash gen-cert.sh
# 语法检查(无测试框架,改完用这个自查)
node -e "new (require('vm').Script)(require('fs').readFileSync('server.js','utf8'));console.log('server ok')"
node -e "const m=require('fs').readFileSync('index.html','utf8').match(/<script>([\s\S]*?)<\/script>/)[1];new (require('vm').Script)(m);console.log('frontend ok')"
# 端到端验证识别流水线(生成中文语音 → 打 /stt)
say -v Tingting "三七二十一" -o /tmp/t.aiff
ffmpeg -y -i /tmp/t.aiff -c:a libopus /tmp/t.webm
curl -s -X POST --data-binary @/tmp/t.webm -H "Content-Type: audio/webm" http://localhost:8000/stt
# 切换到更快的 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/三七二十一/答案是二十一 等都应判对),这是最易回归的部分。
运行环境依赖(非 npm)
后端 shell out 到两个系统命令,缺一不可:
ffmpeg—— 把浏览器录音(桌面webm/opus、iOSmp4/aac)统一转 16k 单声道 wavwhisper-cli—— whisper.cpp 的离线识别二进制(brew install whisper-cpp)
模型放在 models/:ggml-small.bin(默认,较准)、ggml-base.bin(较快)。识别全程离线,不联网、不上云。
Architecture
后端 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?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 / 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()按权重随机抽题 → 错题/慢题更常出现,练熟自动减少。权重同时看对错和用时(elapsedvsCONFIG.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)、重录次数等。首页「⚙️ 家长设置」面板直接绑定其中常用项。
平台约束(重要,别踩坑)
- iOS 语音必须 HTTPS + 受信任证书。iOS 上所有浏览器都是 WebKit,
http://IP下苹果直接禁麦克风、不弹授权;且浏览器自带的SpeechRecognition在 iOS 基本不可用 —— 这正是要走「录音上传后端识别」而非浏览器识别的原因。iOS 首次需下载/cert并在「设置 → 通用 → 证书信任设置」里开完全信任,详见README.md。 - 音频/麦克风/TTS 必须在用户手势内解锁:
ensureAudio()在首页点「开始」(startGame)时调用一次getUserMedia+AudioContext.resume()+ 静音 TTS,之后才能全自动。 - 证书 SAN 里写死了本机 LAN IP;IP 变了必须
gen-cert.sh重新生成。