diff --git a/CLAUDE.md b/CLAUDE.md index d414887..8c8b099 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`)、重录次数等。首页「⚙️ 家长设置」面板直接绑定其中常用项。 diff --git a/eval.js b/eval.js new file mode 100644 index 0000000..f57daf8 --- /dev/null +++ b/eval.js @@ -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 + * + * 判定标准: + * 正确说法 → 前端决策必须是 correct(retry/wrong 都算失败) + * 错误说法 → 前端决策必须不是 correct(retry/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(/