Files
calx/CLAUDE.md
YANG JIANKUAN 7147914a78 docs: 更新 CLAUDE.md 与 README.md 至当前代码状态
补充 eval.js/grammar 文件说明、三种模式与口诀范围介绍、
/stt 带题目参数的示例、多音色评测的音色全名注意事项

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:37:30 +08:00

73 lines
9.2 KiB
Markdown
Raw Permalink 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.

# 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 依赖)、`grammar/number.gbnf`whisper 解码语法约束的静态兜底)、`eval.js`(识别回归评测,零依赖)。没有构建步骤、没有 `package.json`、没有 `node_modules`、没有测试框架。
## Commands
```bash
# 新电脑一键部署(幂等:装依赖 → 补模型 → 启动,见 DEPLOY.md
bash bootstrap.sh
# 启动HTTP:8000麦克风需安全上下文本机 localhost 或部署时挂 HTTPS 反向代理)
bash start.sh # 或 node server.js
# 语法检查(无测试框架,改完用这个自查)
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带 a/b 走按题动态 grammar
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?a=3&b=7"
# 切换到更快的 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`、iOS `mp4/aac`)统一转 16k 单声道 wav
- `whisper-cli` —— whisper.cpp 的离线识别二进制(`brew install whisper-cpp`
模型放在 `models/``ggml-small.bin`(默认,较准)、`ggml-base.bin`(较快)。识别全程离线,不联网、不上云。
## Architecture
### 后端 `server.js`(纯 Node `http`,无框架无依赖)
- 单个 `handler(req,res)` 挂在 HTTP(8000)。不做 HTTPS——本机用 `localhost`(安全上下文),对外部署挂 HTTPS 反向代理。
- 路由:`GET /`index.html`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()` 按权重随机抽题 → 错题/慢题更常出现,练熟自动减少。权重同时看**对错**和**用时**`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`
读题开关、口诀范围(`tableMin/tableMax`)、限时秒数、随机题数、错题加权系数、抢答参数(`matchBuzzMs`/`matchAnswerMs`/`matchQuestions`/加减分、VAD 灵敏度(`vadThreshold`/`vadSilenceMs`)、重录次数等。首页「⚙️ 家长设置」面板直接绑定其中常用项。
## 平台约束(重要,别踩坑)
- **麦克风只在安全上下文可用**(浏览器硬限制):本机开发/运行用 `http://localhost:8000`;对外部署把服务挂在支持 HTTPS 的反向代理后面(项目自身不做 TLS。浏览器自带的 `SpeechRecognition` 兼容性差——这正是走「录音上传后端识别」而非浏览器识别的原因。
- **音频/麦克风/TTS 必须在用户手势内解锁**`ensureAudio()` 在首页点「开始」(`startGame`/`enterMatch`)时调用一次 `getUserMedia` + `AudioContext.resume()` + 静音 TTS之后才能全自动。