init: init proj
This commit is contained in:
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
小学生乘法口诀(九九表)语音背诵小程序。核心交互:**自动读题 → 小朋友开口说答案 → 本地离线语音识别判分**,全程免手(hands-free)。两种模式:顺序闯关、随机挑战(随机模式对错题动态加权)。
|
||||
|
||||
只有两份代码文件:`index.html`(前端单页,含全部 UI/逻辑)和 `server.js`(后端,零 npm 依赖)。没有构建步骤、没有 `package.json`、没有 `node_modules`、没有测试框架。
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 启动(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
|
||||
```
|
||||
|
||||
**改动 `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`/`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` 用中文数字示例做偏置,提升「得数」识别率。
|
||||
|
||||
### 前端 `index.html`(单文件,三个「页面」用 `.hide` 切换:home / game / end)
|
||||
- **口诀数据**:`buildProblems()` 生成 45 条三角表(a≤b),每条带 `weight`(随机模式加权用)。`kouOf()`/`numToCn()` 生成传统口诀读法(如 `三四十二`、`二五一十`)。
|
||||
- **判分核心**(改这里要特别小心,容错是重点需求):`judge(transcript,a,b,p)` 先剥掉标点/运算词/口语填充词、再剥掉句首因数前缀(正反序、中/数字),然后 `parseNumToken`/`extractLastNumber` 把中文数字或阿拉伯数字解析成整数与乘积比对。要同时容忍 `二十一`/`二一`/`21`/完整口诀/句尾数字等说法。
|
||||
- **免手录音闭环**(关键状态机):`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()`。
|
||||
|
||||
### 前端所有可调参数集中在 `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` 重新生成。
|
||||
117
DEPLOY.md
Normal file
117
DEPLOY.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 🚀 部署指南(迁移到新电脑)
|
||||
|
||||
本项目 = 一个前端单页 `index.html` + 一个零依赖 Node 后端 `server.js` + 本地离线识别模型。
|
||||
没有构建、没有 `npm install`、没有云服务。下面按「最快路径」走。
|
||||
|
||||
---
|
||||
|
||||
## 一分钟看懂:需要带什么、装什么
|
||||
|
||||
| 类别 | 内容 | 说明 |
|
||||
|---|---|---|
|
||||
| **拷贝的文件** | 整个 `calx/` 目录 | 见下方清单。`models/`(模型)建议一起拷,省下载 |
|
||||
| **要装的软件** | Node.js、`ffmpeg`、`whisper-cpp` | 前者官网/brew,后两者 `brew install` |
|
||||
| **不用带** | `node_modules`(没有)、`certs/`(新机自动生成) | 证书含本机 IP,换机必须重新生成 |
|
||||
|
||||
必须一起迁移的文件清单:
|
||||
```
|
||||
index.html server.js bootstrap.sh start.sh gen-cert.sh
|
||||
models/ggml-small.bin (465MB,必需)
|
||||
models/ggml-base.bin (141MB,可选,更快)
|
||||
README.md DEPLOY.md CLAUDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式 A:一键部署(推荐,macOS)
|
||||
|
||||
前提:新电脑已装 [Node.js](https://nodejs.org) 和 [Homebrew](https://brew.sh)。
|
||||
|
||||
```bash
|
||||
cd calx
|
||||
bash bootstrap.sh
|
||||
```
|
||||
|
||||
脚本会自动:检查 Node → 装 `ffmpeg`/`whisper-cpp` → 补下模型(若缺)→ 生成自签证书 → 启动服务。
|
||||
可反复运行,已装的会跳过。
|
||||
|
||||
启动后终端打印:
|
||||
```
|
||||
HTTP : http://localhost:8000 http://<本机IP>:8000
|
||||
HTTPS: https://<本机IP>:8443
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方式 B:手动部署(逐步)
|
||||
|
||||
```bash
|
||||
# 1. 装依赖
|
||||
brew install ffmpeg whisper-cpp # Node 另装:brew install node
|
||||
|
||||
# 2. 补模型(若没随目录拷贝)
|
||||
mkdir -p models && cd models
|
||||
curl -L -o ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin
|
||||
cd ..
|
||||
|
||||
# 3. 生成证书 + 启动
|
||||
bash gen-cert.sh
|
||||
node server.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 各端怎么访问
|
||||
|
||||
### 💻 电脑本机
|
||||
Chrome / Edge / Safari 打开 `http://localhost:8000` —— 麦克风直接可用,点「开始」授权即可。
|
||||
|
||||
### 📱 iPhone / iPad(要用语音 → 必须 HTTPS + 信任证书,一次性)
|
||||
> iOS 只有在「HTTPS + 受信任证书」下才允许网页用麦克风,这是苹果硬限制。用 **Safari** 完成:
|
||||
|
||||
1. 手机与电脑连**同一 Wi-Fi**(或你的远程方案能到 `8443` 端口)。
|
||||
2. Safari 打开 `https://<电脑IP>:8443/cert` → 下载描述文件(提示不受信任,继续)。
|
||||
3. **设置 → 通用 → VPN 与设备管理** → 安装 `calx-local`。
|
||||
4. **设置 → 通用 → 关于本机 → 证书信任设置** → 打开对 `calx-local` 的**完全信任**。
|
||||
5. Safari 打开 `https://<电脑IP>:8443/` → 点「开始」→ **允许麦克风** → 全程免手。
|
||||
|
||||
### 🤖 安卓 / 其它
|
||||
Chrome 打开 `https://<电脑IP>:8443/`,首次会警告证书不受信 → 「高级 → 继续前往」→ 允许麦克风。
|
||||
|
||||
---
|
||||
|
||||
## 配置与运维
|
||||
|
||||
```bash
|
||||
# 换更快的模型(识别快一点、准一点点降低)
|
||||
WHISPER_MODEL=models/ggml-base.bin node server.js
|
||||
|
||||
# 改端口
|
||||
PORT=8000 HTTPS_PORT=8443 node server.js
|
||||
|
||||
# 电脑 IP 变了(换网络) → 证书失效,重新生成,手机重做信任步骤
|
||||
bash gen-cert.sh
|
||||
```
|
||||
|
||||
前端玩法参数(读题开关 / 限时秒数 / 随机题数 / 错题加权 / 静音检测灵敏度)在 `index.html` 顶部 `CONFIG`,或游戏首页「⚙️ 家长设置」里改常用项。
|
||||
|
||||
---
|
||||
|
||||
## 排障速查
|
||||
|
||||
| 现象 | 原因 / 处理 |
|
||||
|---|---|
|
||||
| 点「开始」不弹麦克风授权 | 不是 HTTPS,或 iOS 证书没「完全信任」→ 重做 iOS 第 2~4 步 |
|
||||
| iOS 证书装了仍拦麦克风 | 证书 SAN 的 IP 与访问 IP 不一致 → `bash gen-cert.sh` 后重装证书 |
|
||||
| 识别没反应 / 不准 | 看服务端日志有无 `[stt] … -> "xxx"`;`/health` 里 `modelExists` 应为 true;环境别太吵、吐字清楚 |
|
||||
| `whisper-cli: command not found` | `brew install whisper-cpp` |
|
||||
| `ffmpeg: command not found` | `brew install ffmpeg` |
|
||||
| 完全不想用语音 | 游戏内点「⌨️ 用手写」,或环境不支持时自动切数字键盘 |
|
||||
|
||||
## 验证部署是否成功
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8000/health # {"ok":true,...,"modelExists":true}
|
||||
curl -sk https://localhost:8443/health # 同上(自签名用 -k)
|
||||
```
|
||||
`modelExists:true` + 两个端口都返回 = 部署成功。
|
||||
84
README.md
Normal file
84
README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 🦉 乘法口诀大闯关
|
||||
|
||||
小学生乘法口诀语音背诵小程序:**自动读题 → 小朋友开口说答案 → 离线语音识别判分**,全程免手。
|
||||
支持顺序闯关 / 随机挑战(错题动态加权,练熟自动减少),限时作答、正/负反馈动效音效。
|
||||
|
||||
语音识别用 **whisper.cpp 本地离线模型**,不联网、不花钱、不上传任何数据到云端。
|
||||
|
||||
---
|
||||
|
||||
## 一、迁移到新电脑需要的文件
|
||||
|
||||
```
|
||||
calx/
|
||||
├── index.html # 前端(单页面)
|
||||
├── server.js # 后端(零 npm 依赖,纯 Node)
|
||||
├── start.sh # 一键启动
|
||||
├── gen-cert.sh # 生成自签名 HTTPS 证书
|
||||
├── models/
|
||||
│ ├── ggml-small.bin # 中文识别模型(默认,较准,465MB)
|
||||
│ └── ggml-base.bin # 备用模型(较快,141MB)
|
||||
└── certs/ # HTTPS 证书(换电脑/换 IP 后重新生成)
|
||||
```
|
||||
|
||||
`certs/` 和 `node_modules` 不用拷(没有 node_modules)。`models/` 必须一起拷,或到新机器重新下载(见下)。
|
||||
|
||||
## 二、新电脑需要的运行环境
|
||||
|
||||
```bash
|
||||
# 1) Node.js(v16+ 即可,本项目用 v22 验证)
|
||||
node -v
|
||||
|
||||
# 2) 系统命令:ffmpeg(转码)+ whisper-cli(识别)
|
||||
brew install ffmpeg whisper-cpp
|
||||
|
||||
# 3) 模型(若没一起拷贝 models/)
|
||||
mkdir -p models && cd models
|
||||
curl -L -o ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin
|
||||
curl -L -o ggml-base.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin
|
||||
cd ..
|
||||
```
|
||||
|
||||
## 三、启动
|
||||
|
||||
```bash
|
||||
bash start.sh
|
||||
# 或: node server.js
|
||||
```
|
||||
|
||||
启动后终端会打印:
|
||||
```
|
||||
HTTP : http://localhost:8000 http://<你的IP>:8000
|
||||
HTTPS: https://<你的IP>:8443
|
||||
```
|
||||
|
||||
- **电脑本机浏览器**(Chrome/Edge/Safari):直接开 `http://localhost:8000`,麦克风可用。
|
||||
- **iPhone / iPad**:必须用 HTTPS,见下一节。
|
||||
|
||||
## 四、iPhone / iPad 用语音(一次性设置)
|
||||
|
||||
iOS 只有在「HTTPS + 受信任证书」下才允许网页用麦克风。步骤(用 **Safari**):
|
||||
|
||||
1. 手机和电脑连**同一 Wi-Fi**(或你自己的远程方式能到 `8443` 端口)。
|
||||
2. Safari 打开 `https://<电脑IP>:8443/cert` → 提示下载描述文件 → 允许。
|
||||
3. **设置 → 通用 → VPN 与设备管理** → 安装刚下载的 `calx-local` 描述文件。
|
||||
4. **设置 → 通用 → 关于本机 → 证书信任设置** → 打开对 `calx-local` 的**完全信任**。
|
||||
5. Safari 打开 `https://<电脑IP>:8443/` → 点“开始” → **允许麦克风** → 之后听题、答题全程免手。
|
||||
|
||||
> 换电脑或电脑 IP 变了:重新 `bash gen-cert.sh` 生成证书,手机重做第 2~4 步。
|
||||
|
||||
## 五、参数配置
|
||||
|
||||
- 前端:`index.html` 顶部 `CONFIG`(读题开关、限时秒数、随机题数、错题加权、静音检测灵敏度等),或首页「⚙️ 家长设置」里直接改常用项。
|
||||
- 后端模型:默认 `ggml-small.bin`。想更快可切 base:
|
||||
```bash
|
||||
WHISPER_MODEL=models/ggml-base.bin node server.js
|
||||
```
|
||||
- 端口:`PORT=8000 HTTPS_PORT=8443`(环境变量可改)。
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
- **点“开始”没弹麦克风授权**:多半不是 HTTPS 或证书没「完全信任」→ 重做第四节。
|
||||
- **识别不准 / 没反应**:确认终端有 `[stt] ... -> "xxx"` 日志;模型加载 `✅`;小朋友吐字清楚、环境别太吵。
|
||||
- **iOS 上没声音读题**:iOS 要求首次交互才解锁语音,点过“开始”后即正常。
|
||||
- **完全不想用语音**:点游戏里「⌨️ 用手写」,或环境不支持时会自动切数字键盘。
|
||||
54
bootstrap.sh
Executable file
54
bootstrap.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 乘法口诀 —— 新电脑一键部署脚本(macOS)
|
||||
# 作用:检查/安装依赖(ffmpeg + whisper-cpp) → 补齐模型 → 生成证书 → 启动服务
|
||||
# 幂等:已装的会跳过,可反复运行
|
||||
# ============================================================
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
echo "==== 乘法口诀 部署脚本 ===="
|
||||
|
||||
# 1) Node
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "❌ 未检测到 Node.js,请先安装:https://nodejs.org 或 brew install node"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Node $(node -v)"
|
||||
|
||||
# 2) Homebrew
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "❌ 未检测到 Homebrew,请先安装:https://brew.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3) ffmpeg + whisper-cli
|
||||
NEED=""
|
||||
command -v ffmpeg >/dev/null 2>&1 || NEED="$NEED ffmpeg"
|
||||
command -v whisper-cli >/dev/null 2>&1 || NEED="$NEED whisper-cpp"
|
||||
if [ -n "$NEED" ]; then
|
||||
echo "📦 安装依赖:$NEED"
|
||||
brew install $NEED
|
||||
else
|
||||
echo "✅ ffmpeg / whisper-cli 已就绪"
|
||||
fi
|
||||
|
||||
# 4) 模型(缺则下载 small;base 可选)
|
||||
mkdir -p models
|
||||
if [ ! -f models/ggml-small.bin ]; then
|
||||
echo "⬇️ 下载 small 模型 (465MB)…"
|
||||
curl -L -o models/ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin
|
||||
else
|
||||
echo "✅ 模型 ggml-small.bin 已存在"
|
||||
fi
|
||||
|
||||
# 5) 自签名证书(iOS 语音必需)
|
||||
if [ ! -f certs/cert.pem ]; then
|
||||
echo "🔐 生成自签名 HTTPS 证书…"
|
||||
bash gen-cert.sh
|
||||
else
|
||||
echo "✅ 证书已存在(IP 变了请手动跑 bash gen-cert.sh 重生成)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==== 部署完成,启动服务 ===="
|
||||
exec node server.js
|
||||
BIN
certs/calx-cert.cer
Normal file
BIN
certs/calx-cert.cer
Normal file
Binary file not shown.
20
certs/cert.pem
Normal file
20
certs/cert.pem
Normal file
@@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDLTCCAhWgAwIBAgIUU3wQsQyuE+9iWOTikINHiQzObmcwDQYJKoZIhvcNAQEL
|
||||
BQAwFTETMBEGA1UEAwwKY2FseC1sb2NhbDAeFw0yNjA3MTgwNzE2MjVaFw0yODEw
|
||||
MjAwNzE2MjVaMBUxEzARBgNVBAMMCmNhbHgtbG9jYWwwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQCrpEVDdf0hl75+SvVvtvV+Qfa/0o3675VaHOe1QBUE
|
||||
el4max9hDCptebApt52uHu15rvm8ZO0wJOX/0MJHYDCl3C9clFtA/QRpMbPv7A+J
|
||||
5zVeBVSAU/VWPBQYmaS62Klm5FFchwNBvPXmv07iJ3aaOBqu4h/5a+tcAEACvmwg
|
||||
mubF3jBVOLsBDi8rlLO2wVaQ98Xyq+vsAQnbgq8nsrznw7VVI05+TEXLMus3KAnU
|
||||
1aS0GnMDVzRUOqsOlBn7zNiVUcxHJD6Rt9Q0WaVJ+cOUixIyPyI9W5lYSIXZAcsN
|
||||
4jt+CZf+r4/iAfVWINzp9aUHgubOhvqlNHGU9BPKgTmrAgMBAAGjdTBzMB0GA1Ud
|
||||
DgQWBBSHKrKKHSaZaJTKnqsqkuwdNQMpRTAfBgNVHSMEGDAWgBSHKrKKHSaZaJTK
|
||||
nqsqkuwdNQMpRTAPBgNVHRMBAf8EBTADAQH/MCAGA1UdEQQZMBeHBMCoCbmHBH8A
|
||||
AAGCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAmFTaG80LZK1fkIt6tYde
|
||||
JyY7+dM/dLOEd5i/0ZB8b/oybmJ5xszqd4obQwHO/X8UdQOazjhtZM1DEmp2BM99
|
||||
w+pPHB6X7or+GXsvh41LoOdbzXMVeCQbjZYbMWJ+Z0PfV55jF55Rsyd0RgQwdEYW
|
||||
vQxTfB1ka2+6BhrSm/cJMDpZynDZsLBMIAl+N/r2aviD4mJ1axkHqoBrUDP3Uz2O
|
||||
tgMoGOKgc3HH6jDFFi99ITo4WBtmPGeprFIn8czVkmZCsyz57bC77nbL2IGC5pbr
|
||||
xe7SV3pgY4KM0kuV5GnjzzjORXCQDbEgIVvt2pZ8mAs4KA2dXtHLefMfQT8dIhYv
|
||||
xw==
|
||||
-----END CERTIFICATE-----
|
||||
28
certs/key.pem
Normal file
28
certs/key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCrpEVDdf0hl75+
|
||||
SvVvtvV+Qfa/0o3675VaHOe1QBUEel4max9hDCptebApt52uHu15rvm8ZO0wJOX/
|
||||
0MJHYDCl3C9clFtA/QRpMbPv7A+J5zVeBVSAU/VWPBQYmaS62Klm5FFchwNBvPXm
|
||||
v07iJ3aaOBqu4h/5a+tcAEACvmwgmubF3jBVOLsBDi8rlLO2wVaQ98Xyq+vsAQnb
|
||||
gq8nsrznw7VVI05+TEXLMus3KAnU1aS0GnMDVzRUOqsOlBn7zNiVUcxHJD6Rt9Q0
|
||||
WaVJ+cOUixIyPyI9W5lYSIXZAcsN4jt+CZf+r4/iAfVWINzp9aUHgubOhvqlNHGU
|
||||
9BPKgTmrAgMBAAECggEAHaT+jXs4X+/sZ7WclFHeLpNIAbNk8D0m52xsnIJX3vEf
|
||||
h7Uis0k9nrDQuzPKWQNgco7E9UPdL16AFuHAXEGBxXO0/mFlLWFFvMFn2X1E5cV4
|
||||
EygKgyt9CQrRUbpfmCHJiErQvpFW/6iaM+NAxZCZTGN6QFAvw41fFgG5DIBE/pBZ
|
||||
NALrPxiWORTY1wnqktEZYa97QKv4NRPCDHnOwS7R5ILps79a0qv6IiKQA8ztC4EI
|
||||
AOA7J9i2G6SZz7wbCfc+8Ndw3Z0a7Y7OMrfuPxHvYlJoZS70o3Et1TFeuKo0FDuV
|
||||
6NdAnQu4UBRMU1i1ggqLFbsM4StJOzI2jeIZmsjFAQKBgQDydUme1jKcxLGPZT2T
|
||||
nvcIPyJm4FjUNNbv8VqW/V6YtFlKt0rEvoGDKQbV0pkcf4MkXPcmDCJbB411g2MR
|
||||
AhThsKVWL0bpYUdB6SX3xEPkKc4r/cHbqXVPSl38Dgqw4qHHaH02qlXBy+RNuwMv
|
||||
uOKvk68pH30EXzzl9RCZhpLLgQKBgQC1Om+J61EtGkEIid5QonvvIViYfgVcfgMy
|
||||
igQD7GnUSb4vNbd1mNPm4sAKHxSp+Hp59uupmjtLr/5ksTZa6ibbXdiX53cXqAsp
|
||||
vzNidP4T+QCkoB1t958QCKRQnd50pijFOUeAa8BuoBCSsYF86Q+WBpt6pHkdXe5H
|
||||
NNnU8FqLKwKBgAn4ZsGgX05xDDgwWpric5gkOH7whNadIaWWUDpbSvOOnwlihjGW
|
||||
/+5r1nsegOtaFwN8yzEGWLdTlS6a64z8f+1T/UiRAyJ/vJVqSARrTrtoOLE4aytk
|
||||
SY2sKjc+QJRLjYmMs3F3CbXBc676nMPun2vG7/IJ+HMgk3mFXVxi21CBAoGAbLmw
|
||||
LRhGTT7RAWRlQg8ZN2/UDTid0s2u8WUn34q4Oyo6tWwOsriLeJtVBz16UjbGk+F1
|
||||
pPzo0hG2F4bRy11rDnSQ+yFcq46fk94n38u8mxLqNdnxjgXfIeltWwg7a9Q75Ybl
|
||||
xL/gZP4Ws0tXGi9gsGsqYDvIhbvStlmYzk2cpJ0CgYAoxb239sleOrqvglo+wogt
|
||||
JK3K74i358BITd1NsB+XsuphQYaYuLc17KLoiGIqyBv6gwfetcn9U+2vmXF1vnwy
|
||||
dLqwhi0ryf0xxrNxOpow1814UK4kuN0uHwhE4Xj+UmdcpjkMyud+jP7qqGbzvfF1
|
||||
ZD4ohLo4O1/U7yLoR3bO5Q==
|
||||
-----END PRIVATE KEY-----
|
||||
12
gen-cert.sh
Executable file
12
gen-cert.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# 生成/更新自签名 HTTPS 证书(IP 变了就重新跑一次)
|
||||
cd "$(dirname "$0")"
|
||||
mkdir -p certs
|
||||
IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1)
|
||||
echo "为本机 IP = $IP 生成自签名证书..."
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout certs/key.pem -out certs/cert.pem -days 825 \
|
||||
-subj "/CN=calx-local" \
|
||||
-addext "subjectAltName=IP:${IP},IP:127.0.0.1,DNS:localhost"
|
||||
openssl x509 -in certs/cert.pem -outform der -out certs/calx-cert.cer
|
||||
echo "完成。iOS 请访问 https://${IP}:8443/cert 下载并信任证书。"
|
||||
10
grammar/number.gbnf
Normal file
10
grammar/number.gbnf
Normal file
@@ -0,0 +1,10 @@
|
||||
# 约束 whisper 只输出「乘法口诀答案」:可选(两因数[得]) + 一个规范数字
|
||||
root ::= prefix? product
|
||||
prefix ::= sd sd "得"?
|
||||
product ::= cn | ar
|
||||
sd ::= "一" | "二" | "三" | "四" | "五" | "六" | "七" | "八" | "九" | [0-9]
|
||||
cn ::= tens | teen | unit | "十"
|
||||
unit ::= "零" | "一" | "二" | "三" | "四" | "五" | "六" | "七" | "八" | "九"
|
||||
teen ::= "十" unit
|
||||
tens ::= unit "十" unit?
|
||||
ar ::= [0-9] [0-9]?
|
||||
651
index.html
Normal file
651
index.html
Normal file
@@ -0,0 +1,651 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>🦉 乘法口诀大闯关</title>
|
||||
<style>
|
||||
:root{
|
||||
--green:#58cc02; --green-d:#58a700; --red:#ff4b4b; --red-d:#d63535;
|
||||
--blue:#1cb0f6; --blue-d:#1899d6; --yellow:#ffc800; --purple:#ce82ff;
|
||||
--ink:#3c3c3c; --gray:#e5e5e5; --gray-t:#afafaf;
|
||||
}
|
||||
*{ 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;
|
||||
}
|
||||
.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; }
|
||||
.hide{ display:none !important; }
|
||||
|
||||
.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);} }
|
||||
.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; }
|
||||
|
||||
.btn{
|
||||
border:none; border-radius:16px; 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;
|
||||
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; }
|
||||
.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-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; }
|
||||
|
||||
.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; }
|
||||
|
||||
.timer{ height:12px; background:var(--gray); border-radius:999px; overflow:hidden; margin:4px 0 18px; }
|
||||
.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 .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; }
|
||||
|
||||
.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);
|
||||
}
|
||||
.bubble .dim{ color:var(--gray-t); font-weight:600; font-size:17px; }
|
||||
|
||||
.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: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);} }
|
||||
.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; }
|
||||
|
||||
.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 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; }
|
||||
|
||||
.tools{ display:flex; gap:8px; margin-top:16px; 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; }
|
||||
.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; }
|
||||
.switch i::after{ content:""; position:absolute; top:3px; left:3px; width:24px; height:24px; background:#fff; border-radius:50%; transition:.2s; }
|
||||
.switch input:checked + i{ background:var(--green); }
|
||||
.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; }
|
||||
|
||||
/* 设置弹窗 */
|
||||
.modal{ position:fixed; inset:0; background:rgba(0,0,0,.38); 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; }
|
||||
@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); }
|
||||
.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); }
|
||||
.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.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.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.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); }
|
||||
|
||||
.flash{ position:fixed; inset:0; pointer-events:none; z-index:40; opacity:0; }
|
||||
.flash.g{ background:radial-gradient(circle,rgba(88,204,2,.28),transparent 70%); animation:fl .6s; }
|
||||
.flash.r{ background:radial-gradient(circle,rgba(255,75,75,.3),transparent 70%); animation:fl .5s; }
|
||||
@keyframes fl{ 0%{opacity:0;} 30%{opacity:1;} 100%{opacity:0;} }
|
||||
.fx{ position:fixed; inset:0; pointer-events:none; overflow:hidden; z-index:50; }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- ===== 首页 ===== -->
|
||||
<div id="home" class="card" style="position:relative">
|
||||
<button class="gear" onclick="openSettings()" title="家长设置">⚙️</button>
|
||||
<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>
|
||||
<p class="note" id="homeNote"></p>
|
||||
</div>
|
||||
|
||||
<!-- ===== 设置弹窗 ===== -->
|
||||
<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>
|
||||
<label class="switch"><input type="checkbox" id="cfgRead" checked onchange="CONFIG.ttsQuestion=this.checked"><i></i></label></div>
|
||||
<div class="set-row"><span>⏱️ 限时作答</span>
|
||||
<label class="switch"><input type="checkbox" id="cfgTimer" checked onchange="CONFIG.timerEnabled=this.checked"><i></i></label></div>
|
||||
<div class="set-row"><span>每题作答秒数</span>
|
||||
<input type="number" id="cfgSec" min="2" max="30" step="1" value="6" oninput="CONFIG.timeLimitMs=Math.max(2,+this.value||6)*1000"></div>
|
||||
<div class="set-row"><span>随机模式题数</span>
|
||||
<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>
|
||||
<p class="note" style="margin-top:6px">随机模式里答错或答慢的口诀会更常出现,练熟后自动减少。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 游戏页 ===== -->
|
||||
<div id="game" class="card hide">
|
||||
<div class="top">
|
||||
<button class="exit" onclick="quit()" title="返回">✕</button>
|
||||
<div class="bar"><i id="barFill"></i></div>
|
||||
<span class="scoretag" id="scorePill">⭐0</span>
|
||||
</div>
|
||||
<div class="timer" id="timerWrap"><i id="timerBar"></i></div>
|
||||
|
||||
<div class="mascot" id="gMascot">🦉</div>
|
||||
<div class="problem" id="problem"></div>
|
||||
<div class="kou-hint" id="kouHint"></div>
|
||||
|
||||
<div class="bubble" id="heard"><span class="dim">准备好啦~</span></div>
|
||||
|
||||
<div class="mic-wrap" id="micWrap">
|
||||
<button class="mic" id="micBtn" onclick="micTap()">🎤</button>
|
||||
<div class="mic-label" id="micLabel">全自动:听题→说答案</div>
|
||||
</div>
|
||||
|
||||
<div id="padWrap" class="hide">
|
||||
<div class="bubble"><span id="padShow" class="dim">点数字输入答案</span></div>
|
||||
<div class="pad">
|
||||
<button onclick="padKey('1')">1</button><button onclick="padKey('2')">2</button><button onclick="padKey('3')">3</button>
|
||||
<button onclick="padKey('4')">4</button><button onclick="padKey('5')">5</button><button onclick="padKey('6')">6</button>
|
||||
<button onclick="padKey('7')">7</button><button onclick="padKey('8')">8</button><button onclick="padKey('9')">9</button>
|
||||
<button class="del" onclick="padDel()">⌫</button><button onclick="padKey('0')">0</button><button class="go" onclick="padGo()">✔</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools">
|
||||
<button class="btn small b-yellow" onclick="speakKou()">🔊 听口诀</button>
|
||||
<button class="btn small b-purple" id="toggleInput" onclick="toggleInputMode()">⌨️ 用手写</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 结算页 ===== -->
|
||||
<div id="end" class="card hide">
|
||||
<div class="mascot bob" id="endMascot">🏆</div>
|
||||
<h1>闯关完成!</h1>
|
||||
<div class="stars" id="endStars"></div>
|
||||
<div class="score-big" id="endScore"></div>
|
||||
<p class="subtitle" id="endMsg"></p>
|
||||
<button class="btn b-green big" onclick="restart()">🔁 再玩一次</button>
|
||||
<button class="btn b-white" onclick="quit()">🏠 回首页</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="sheet" id="sheet">
|
||||
<div class="head"><span class="icon" id="sheetIcon">🎉</span>
|
||||
<div><div class="s-title" id="sheetTitle"></div><div class="s-sub" id="sheetSub"></div></div></div>
|
||||
<button class="cont" id="contBtn">继续 →</button>
|
||||
</div>
|
||||
<div class="flash" id="flash"></div>
|
||||
<div class="fx" id="fx"></div>
|
||||
|
||||
<script>
|
||||
/* ============ 可配置项 ============ */
|
||||
const CONFIG = {
|
||||
ttsQuestion:true, // 自动朗读题目
|
||||
readRate:0.6, // 读题语速(1=正常,越小越慢)
|
||||
timerEnabled:true, // 限时作答
|
||||
timeLimitMs:6000, // 每题作答窗口(读题后开始计时/录音)
|
||||
sessionLength:20, // 随机模式一局题数
|
||||
slowMs:3000, // 超过多久算“答得慢”
|
||||
// 随机模式错题加权
|
||||
wrongPenalty:4, timeoutPenalty:5, slowAddPenalty:1.5,
|
||||
correctFastFactor:0.4, correctSlowFactor:0.8, minWeight:1, maxWeight:14,
|
||||
seqTeachAfter:1, // 顺序模式错几次后自动念口诀
|
||||
// 录音 / 静音检测(VAD)
|
||||
vadThreshold:0.018, // 音量阈值(0-1),超过算“在说话”
|
||||
vadStartMs:150, // 连续多久有声音算“开口了”
|
||||
vadSilenceMs:1100, // 开口后静音多久算“说完了”(放宽以容忍背口诀“三七…二十一”中间的停顿)
|
||||
maxAttempts:3, // 一题内没听清最多自动重录几次
|
||||
noMicWindowMs:8000, // 关闭限时时的最长录音窗口
|
||||
};
|
||||
|
||||
/* ============ 口诀数据 ============ */
|
||||
const CN = ['零','一','二','三','四','五','六','七','八','九'];
|
||||
function numToCn(n){ if(n<10)return CN[n]; if(n===10)return '一十'; if(n<20)return '十'+CN[n%10];
|
||||
const t=Math.floor(n/10),o=n%10; return CN[t]+'十'+(o?CN[o]:''); }
|
||||
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});
|
||||
return list; }
|
||||
|
||||
/* ============ 中文/数字解析 ============ */
|
||||
const DIG = {零:0,〇:0,一:1,幺:1,二:2,两:2,兩:2,三:3,四:4,五:5,六:6,七:7,八:8,九: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);
|
||||
const tens=(a==='')?1:(a in DIG?DIG[a]:NaN); const ones=(b==='')?0:(b in DIG?DIG[b]:NaN);
|
||||
if(isNaN(tens)||isNaN(ones)) return null; return tens*10+ones; }
|
||||
let n=0,ok=false; for(const ch of s){ if(ch in DIG){n=n*10+DIG[ch];ok=true;} else return null; } return ok?n:null;
|
||||
}
|
||||
function extractLastNumber(text){
|
||||
const t=(text||'').replace(/\s/g,''); let toks=[],cur='';
|
||||
const isNum=ch=> /[0-9]/.test(ch) || (ch in DIG) || ch==='十';
|
||||
for(const ch of t){ if(isNum(ch)) cur+=ch; else { if(cur){toks.push(cur);cur='';} } }
|
||||
if(cur) toks.push(cur);
|
||||
const nums=toks.map(parseNumToken).filter(n=>n!==null);
|
||||
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,'')
|
||||
.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; } }
|
||||
if(parseNumToken(t)===p) return true;
|
||||
if(extractLastNumber(t)===p) return true;
|
||||
if(extractLastNumber(transcript)===p) return true;
|
||||
return false;
|
||||
}
|
||||
// 是否“只念了口诀前半(因数)还没说得数”,如背到“三七…”停顿被切断 → 不算错,继续听
|
||||
function isJustFactors(transcript,a,b){
|
||||
let t=(transcript||'').replace(/[\s.,,。!!??、~~·==]/g,'')
|
||||
.replace(/得到|得|等于|结果|答案|应该|就是|是|的|啦|了|嘛|呀|哦|噢|嗯|呃|那|个/g,'')
|
||||
.replace(/乘以|乘上|乘|×|✕|X|x|\*/g,'');
|
||||
if(!t) return false;
|
||||
const forms=[CN[a]+CN[b], ''+a+b, CN[b]+CN[a], ''+b+a];
|
||||
if(forms.includes(t)) return true;
|
||||
const n=extractLastNumber(t);
|
||||
return n!==null && n!==a*b && (n===a*10+b || n===b*10+a || n===a || n===b);
|
||||
}
|
||||
|
||||
/* ============ 语音播放(TTS) ============ */
|
||||
let zhVoice=null;
|
||||
function pickVoice(){ const vs=speechSynthesis.getVoices(); zhVoice=vs.find(v=>/zh/i.test(v.lang))||vs[0]||null; }
|
||||
if('speechSynthesis' in window){ pickVoice(); speechSynthesis.onvoiceschanged=pickVoice; }
|
||||
function speak(text){ if(!('speechSynthesis' in window))return; speechSynthesis.cancel();
|
||||
const u=new SpeechSynthesisUtterance(text); u.lang='zh-CN'; u.rate=.9; u.pitch=1.15; if(zhVoice)u.voice=zhVoice; speechSynthesis.speak(u); }
|
||||
function speakAsync(text,cancelFirst){
|
||||
return new Promise(res=>{
|
||||
if(!('speechSynthesis' in window)){ res(); return; }
|
||||
if(cancelFirst) speechSynthesis.cancel();
|
||||
const u=new SpeechSynthesisUtterance(text); u.lang='zh-CN'; u.rate=CONFIG.readRate; u.pitch=1.15; if(zhVoice)u.voice=zhVoice;
|
||||
let done=false; const fin=()=>{ if(!done){done=true;res();} };
|
||||
u.onend=fin; u.onerror=fin; speechSynthesis.speak(u);
|
||||
setTimeout(fin, 4500); // iOS 兜底
|
||||
});
|
||||
}
|
||||
|
||||
/* ============ 录音 + 静音检测 + 后端识别 ============ */
|
||||
let mediaStream=null, audioCtx=null, analyser=null, vadBuf=null;
|
||||
let mediaRecorder=null, recChunks=[], recording=false, recStart=0,
|
||||
speechStarted=false, lastVoiceAt=0, voiceRun=0, vadRAF=null, attempts=0;
|
||||
const canRecord = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
|
||||
|
||||
async function ensureAudio(){
|
||||
// 解锁 iOS 的 TTS(需在用户手势内朗读一次)
|
||||
try{ speechSynthesis.cancel(); const u=new SpeechSynthesisUtterance(' '); u.volume=0; speechSynthesis.speak(u); }catch(e){}
|
||||
if(mediaStream) { if(audioCtx&&audioCtx.state==='suspended'){ try{await audioCtx.resume();}catch(e){} } return true; }
|
||||
if(!canRecord) return false;
|
||||
try{
|
||||
mediaStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}});
|
||||
const AC=window.AudioContext||window.webkitAudioContext;
|
||||
audioCtx=new AC(); if(audioCtx.state==='suspended'){ try{await audioCtx.resume();}catch(e){} }
|
||||
const src=audioCtx.createMediaStreamSource(mediaStream);
|
||||
analyser=audioCtx.createAnalyser(); analyser.fftSize=1024; vadBuf=new Float32Array(analyser.fftSize);
|
||||
src.connect(analyser);
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
function pickMime(){
|
||||
if(!window.MediaRecorder) return '';
|
||||
const opts=['audio/webm;codecs=opus','audio/webm','audio/mp4','audio/aac','audio/ogg'];
|
||||
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 startRecordCycle(){
|
||||
if(!mediaStream || answered) return;
|
||||
recChunks=[]; speechStarted=false; voiceRun=0; lastVoiceAt=0;
|
||||
const mime=pickMime();
|
||||
try{ mediaRecorder = mime? new MediaRecorder(mediaStream,{mimeType:mime}) : new MediaRecorder(mediaStream); }
|
||||
catch(e){ try{ mediaRecorder=new MediaRecorder(mediaStream); }catch(_){ switchToPad(); return; } }
|
||||
mediaRecorder.ondataavailable=e=>{ if(e.data&&e.data.size) recChunks.push(e.data); };
|
||||
mediaRecorder.onstop=onRecStop;
|
||||
recording=true;
|
||||
try{ mediaRecorder.start(); }catch(e){ recording=false; switchToPad(); return; }
|
||||
recStart=Date.now();
|
||||
micBtn.className='mic listening'; micLabel.textContent='我在听… 说答案!';
|
||||
heard.innerHTML='<span class="dim">🎙️ 请说答案…</span>';
|
||||
startTimer();
|
||||
vadRAF=requestAnimationFrame(vadTick);
|
||||
}
|
||||
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();
|
||||
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; } // 到时间
|
||||
vadRAF=requestAnimationFrame(vadTick);
|
||||
}
|
||||
function stopRecord(){
|
||||
if(!recording) return; recording=false;
|
||||
if(vadRAF){ cancelAnimationFrame(vadRAF); vadRAF=null; }
|
||||
clearTimer(); micBtn.className='mic';
|
||||
try{ if(mediaRecorder && mediaRecorder.state!=='inactive') mediaRecorder.stop(); }catch(e){}
|
||||
}
|
||||
async function onRecStop(){
|
||||
if(answered) return;
|
||||
const type=(mediaRecorder&&mediaRecorder.mimeType)||'audio/webm';
|
||||
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); }
|
||||
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 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); }
|
||||
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);
|
||||
}
|
||||
else if(text && hasNumber(text)){ resolve(false,false); } // 说了个数、但答错
|
||||
else { // 没识别出数字 → 再给机会
|
||||
attempts++;
|
||||
if(attempts>=CONFIG.maxAttempts){ resolve(false,false); }
|
||||
else setTimeout(()=>{ 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);
|
||||
}
|
||||
}
|
||||
async function autoAsk(){
|
||||
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(); } // 再答一次
|
||||
}
|
||||
|
||||
/* ============ 计时器 ============ */
|
||||
let timerId=null, qStart=0;
|
||||
function clearTimer(){ if(timerId){ clearInterval(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;
|
||||
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);
|
||||
}
|
||||
|
||||
/* ============ 游戏状态 ============ */
|
||||
let mode='seq', problems=[], idx=0, score=0, cur=null, padMode=false, answered=false,
|
||||
answeredCount=0, missCount=0, lastKey='', pendingAdv=null, autoActive=false;
|
||||
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');
|
||||
|
||||
let seed=(Date.now()%100000)+1;
|
||||
function rnd(){ seed=(seed*9301+49297)%233280; return seed/233280; }
|
||||
|
||||
function pickWeighted(){
|
||||
let total=0; for(const p of problems) total+=p.weight;
|
||||
for(let tries=0;tries<6;tries++){
|
||||
let r=rnd()*total, chosen=problems[0];
|
||||
for(const p of problems){ r-=p.weight; if(r<=0){ chosen=p; break; } }
|
||||
if((chosen.a+'x'+chosen.b)!==lastKey || problems.length<2) return chosen;
|
||||
}
|
||||
return problems[Math.floor(rnd()*problems.length)];
|
||||
}
|
||||
|
||||
async function startGame(m){
|
||||
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;
|
||||
const ok = await ensureAudio(); // 在“开始”手势内解锁音频并申请麦克风
|
||||
if(ok){ autoActive=true; $('micWrap').classList.remove('hide'); $('padWrap').classList.add('hide'); $('toggleInput').innerHTML='⌨️ 用手写'; }
|
||||
else { autoActive=false; switchToPad(); micLabel.textContent='麦克风不可用'; }
|
||||
nextQuestion();
|
||||
}
|
||||
|
||||
function totalTarget(){ return mode==='seq'? problems.length : CONFIG.sessionLength; }
|
||||
function progressDone(){ return mode==='seq'? idx : answeredCount; }
|
||||
|
||||
function nextQuestion(){
|
||||
if( (mode==='seq' && idx>=problems.length) || (mode==='rand' && answeredCount>=CONFIG.sessionLength) ) return finish();
|
||||
cur = (mode==='seq') ? problems[idx] : pickWeighted();
|
||||
lastKey = cur.a+'x'+cur.b; missCount=0;
|
||||
renderQuestion();
|
||||
}
|
||||
function retrySame(){ renderQuestion(); }
|
||||
|
||||
function renderQuestion(){
|
||||
answered=false; attempts=0;
|
||||
problem.innerHTML = `${cur.a} × ${cur.b} = <span class="q">?</span>`;
|
||||
problem.classList.remove('pop'); void problem.offsetWidth; problem.classList.add('pop');
|
||||
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); }
|
||||
else { heard.innerHTML='<span class="dim">点数字输入答案</span>'; if(CONFIG.ttsQuestion) speakAsync(kouPrompt(cur.a,cur.b),true); startTimer(); }
|
||||
}
|
||||
|
||||
function resolve(correct, timedOut){
|
||||
if(answered) return; answered=true;
|
||||
const elapsed = Date.now()-qStart;
|
||||
clearTimer(); recording=false; if(vadRAF){ cancelAnimationFrame(vadRAF); vadRAF=null; }
|
||||
try{ if(mediaRecorder && mediaRecorder.state==='recording') mediaRecorder.stop(); }catch(e){}
|
||||
micBtn.className='mic';
|
||||
|
||||
if(mode==='rand') updateWeight(cur, correct && !timedOut, timedOut, elapsed);
|
||||
|
||||
if(correct && !timedOut){
|
||||
score++; $('scorePill').textContent='⭐'+score;
|
||||
if(mode==='seq') idx++; else answeredCount++;
|
||||
$('barFill').style.width=(progressDone()/totalTarget()*100)+'%';
|
||||
positive(elapsed); speak(cur.kou);
|
||||
afterSheet('ok', elapsed<CONFIG.slowMs?'太棒了! 🎉':'答对啦! 👍', cur.kou, nextQuestion, 1500);
|
||||
} else {
|
||||
negative(timedOut);
|
||||
if(mode==='seq'){
|
||||
missCount++; if(missCount>=CONFIG.seqTeachAfter) speak(cur.kou);
|
||||
afterSheet('no', timedOut?'时间到! ⏰':'再试试~ 💪', '正确口诀:'+cur.kou, retrySame, 1800);
|
||||
} else {
|
||||
answeredCount++; $('barFill').style.width=(progressDone()/totalTarget()*100)+'%'; speak(cur.kou);
|
||||
afterSheet('no', timedOut?'时间到! ⏰':'答错了 💪', '正确:'+cur.a+'×'+cur.b+'='+cur.p+'('+cur.kou+')', nextQuestion, 1900);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateWeight(p, correct, timedOut, elapsed){
|
||||
if(timedOut){ p.weight = Math.min(CONFIG.maxWeight, p.weight + CONFIG.timeoutPenalty); return; }
|
||||
if(!correct){ p.weight = Math.min(CONFIG.maxWeight, p.weight + CONFIG.wrongPenalty); return; }
|
||||
const slow = elapsed > CONFIG.slowMs;
|
||||
p.weight = Math.max(CONFIG.minWeight, p.weight * (slow?CONFIG.correctSlowFactor:CONFIG.correctFastFactor));
|
||||
if(slow) p.weight = Math.min(CONFIG.maxWeight, p.weight + CONFIG.slowAddPenalty);
|
||||
}
|
||||
|
||||
/* ============ 结果卡 + 反馈 ============ */
|
||||
function afterSheet(type,title,sub,thenFn,delay){
|
||||
const s=$('sheet'); s.className='sheet '+type+' show';
|
||||
$('sheetIcon').textContent = type==='ok'?'🎉':'💡';
|
||||
$('sheetTitle').textContent=title; $('sheetSub').textContent=sub;
|
||||
const go=()=>{ s.classList.remove('show'); thenFn&&thenFn(); };
|
||||
clearTimeout(pendingAdv); pendingAdv=setTimeout(go,delay);
|
||||
$('contBtn').onclick=()=>{ clearTimeout(pendingAdv); go(); };
|
||||
}
|
||||
function positive(elapsed){
|
||||
gMascot.textContent = elapsed<CONFIG.slowMs?'🥳':'😄';
|
||||
gMascot.classList.remove('pop'); void gMascot.offsetWidth; gMascot.classList.add('pop');
|
||||
kouHint.textContent=cur.kou; beep(true); flash('g'); confetti(elapsed<CONFIG.slowMs?22:14);
|
||||
}
|
||||
function negative(timedOut){
|
||||
gMascot.textContent = timedOut?'😮':'🤔';
|
||||
problem.classList.remove('shake'); void problem.offsetWidth; problem.classList.add('shake');
|
||||
kouHint.textContent=cur.kou; beep(false); flash('r');
|
||||
}
|
||||
function flash(kind){ const f=$('flash'); f.className='flash'; void f.offsetWidth; f.className='flash '+kind; }
|
||||
|
||||
/* ============ 听口诀 / 键盘 / 退出 ============ */
|
||||
function speakKou(){
|
||||
if(recording) stopRecord();
|
||||
clearTimer(); kouHint.textContent=cur.kou;
|
||||
speakAsync(cur.kou,true).then(()=>{ if(!answered && autoActive) startRecordCycle(); });
|
||||
}
|
||||
let padBuf='';
|
||||
function toggleInputMode(){ padMode ? switchToMic() : switchToPad(); }
|
||||
function switchToPad(){
|
||||
padMode=true; autoActive=false; if(recording) stopRecord(); clearTimer();
|
||||
$('micWrap').classList.add('hide'); $('padWrap').classList.remove('hide'); $('toggleInput').innerHTML='🎤 用说的';
|
||||
if(!answered){ startTimer(); }
|
||||
}
|
||||
function switchToMic(){
|
||||
if(!mediaStream){ ensureAudio().then(ok=>{ if(ok){ padMode=false; autoActive=true; $('padWrap').classList.add('hide'); $('micWrap').classList.remove('hide'); $('toggleInput').innerHTML='⌨️ 用手写'; if(!answered) autoAsk(); } }); return; }
|
||||
padMode=false; autoActive=true;
|
||||
$('padWrap').classList.add('hide'); $('micWrap').classList.remove('hide'); $('toggleInput').innerHTML='⌨️ 用手写';
|
||||
if(!answered) autoAsk();
|
||||
}
|
||||
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 finish(){
|
||||
clearTimer(); if(recording) stopRecord(); $('barFill').style.width='100%';
|
||||
const total=totalTarget(), pct= total? score/total : 0;
|
||||
$('endStars').textContent = pct>=.9?'⭐⭐⭐': pct>=.6?'⭐⭐':'⭐';
|
||||
$('endScore').textContent = score+' / '+total;
|
||||
$('endMascot').textContent = pct>=.9?'🏆': pct>=.6?'🎖️':'🌟';
|
||||
$('endMsg').textContent = pct>=.9?'太厉害啦,口诀全会背!': pct>=.6?'很棒!再多练几次就满分!':'继续加油,你一定行!';
|
||||
game.classList.add('hide'); end.classList.remove('hide');
|
||||
speak(pct>=.9?'太厉害啦!':'闯关完成,继续加油!'); bigConfetti();
|
||||
}
|
||||
function restart(){ startGame(mode); }
|
||||
function quit(){
|
||||
clearTimer(); if(recording) stopRecord(); clearTimeout(pendingAdv);
|
||||
speechSynthesis&&speechSynthesis.cancel(); $('sheet').classList.remove('show');
|
||||
game.classList.add('hide'); end.classList.add('hide'); home.classList.remove('hide');
|
||||
}
|
||||
|
||||
/* ============ 音效 ============ */
|
||||
function beep(good){
|
||||
try{
|
||||
const AC=window.AudioContext||window.webkitAudioContext;
|
||||
audioCtx = audioCtx || new AC(); if(audioCtx.state==='suspended') audioCtx.resume();
|
||||
const notes = good?[523,659,784,1047]:[320,200];
|
||||
notes.forEach((f,i)=>{
|
||||
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
|
||||
o.type= good?'triangle':'sawtooth'; o.frequency.value=f; o.connect(g); g.connect(audioCtx.destination);
|
||||
const t=audioCtx.currentTime+i*(good?0.08:0.12);
|
||||
g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(good?0.22:0.28,t+0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001,t+(good?0.2:0.28));
|
||||
o.start(t); o.stop(t+0.3);
|
||||
});
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
/* ============ 彩带 ============ */
|
||||
const EMO=['⭐','🎉','🌟','✨','🎈','🍬','💛','🩵','🏆'];
|
||||
function confetti(n){ burst(n||16); }
|
||||
function bigConfetti(){ burst(40); setTimeout(()=>burst(40),350); setTimeout(()=>burst(40),700); }
|
||||
function burst(n){ const fx=$('fx');
|
||||
for(let i=0;i<n;i++){ const s=document.createElement('span'); s.textContent=EMO[Math.floor(rnd()*EMO.length)];
|
||||
s.style.left=(rnd()*100)+'vw'; s.style.animationDelay=(rnd()*0.3)+'s'; s.style.fontSize=(20+rnd()*22)+'px';
|
||||
fx.appendChild(s); setTimeout(()=>s.remove(),1900); } }
|
||||
|
||||
/* ============ 设置弹窗 ============ */
|
||||
function openSettings(){ $('settingsModal').classList.remove('hide'); }
|
||||
function closeSettings(){ $('settingsModal').classList.add('hide'); }
|
||||
|
||||
/* ============ 首页提示(一行) ============ */
|
||||
$('homeNote').innerHTML = canRecord
|
||||
? '💡 点“开始”后允许麦克风即可全程免手'
|
||||
: '💡 当前环境不支持录音,将用数字键盘作答';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
156
server.js
Normal file
156
server.js
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/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 = '以下是小学生背诵乘法口诀说出的答案,通常是一个中文数字,例如:一、二、六、九、十二、二十一、四十九、八十一。';
|
||||
|
||||
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) {
|
||||
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.3s 静音
|
||||
// (关键:短音节如“一/八”若无留白,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]);
|
||||
if (ff.code !== 0 || !fs.existsSync(wavFile)) { cleanup(); return { text: '', error: 'ffmpeg failed: ' + ff.err.slice(0, 200) }; }
|
||||
// 2) whisper.cpp 离线识别:语法约束解码,强制输出成合法数字,杜绝英文/乱码幻听
|
||||
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');
|
||||
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 out = await transcribe(buf, req.headers['content-type']);
|
||||
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)'}`);
|
||||
});
|
||||
|
||||
// ---- HTTPS(iOS 用麦克风必需)----
|
||||
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); }
|
||||
Reference in New Issue
Block a user