From d9758592867936455d1e4b9401d6a72c3487580f Mon Sep 17 00:00:00 2001 From: Kid Date: Sat, 18 Jul 2026 16:48:41 +0800 Subject: [PATCH] init: init proj --- CLAUDE.md | 66 +++++ DEPLOY.md | 117 ++++++++ README.md | 84 ++++++ bootstrap.sh | 54 ++++ certs/calx-cert.cer | Bin 0 -> 817 bytes certs/cert.pem | 20 ++ certs/key.pem | 28 ++ gen-cert.sh | 12 + grammar/number.gbnf | 10 + index.html | 651 ++++++++++++++++++++++++++++++++++++++++++++ server.js | 156 +++++++++++ start.sh | 6 + 12 files changed, 1204 insertions(+) create mode 100644 CLAUDE.md create mode 100644 DEPLOY.md create mode 100644 README.md create mode 100755 bootstrap.sh create mode 100644 certs/calx-cert.cer create mode 100644 certs/cert.pem create mode 100644 certs/key.pem create mode 100755 gen-cert.sh create mode 100644 grammar/number.gbnf create mode 100644 index.html create mode 100644 server.js create mode 100755 start.sh diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d414887 --- /dev/null +++ b/CLAUDE.md @@ -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(/ + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..0f12ba2 --- /dev/null +++ b/server.js @@ -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); } diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..2f55086 --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# 一键启动乘法口诀服务 +cd "$(dirname "$0")" +# 没有证书就先生成 +[ -f certs/cert.pem ] || bash gen-cert.sh +exec node server.js