#!/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 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 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 = '以下是小学生背诵乘法口诀说出的答案,通常是一个中文数字,例如:一、二、六、九、十二、二十一、四十九、八十一。'; // ---- 按题动态生成 grammar:前缀锁死为本题因数,得数保持全开放(不会把答错扶正)---- // 只允许中文数字输出:阿拉伯数字会把「十四/七十四」压扁成 14/74,丢掉「十」的信息 const CN = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; const GRAMMAR_NUM = [ 'product ::= tens | teen | unit | "十"', 'unit ::= "零" | "一" | "二" | "三" | "四" | "五" | "六" | "七" | "八" | "九" | alias', // 发音兼容别名:n/l 不分(六→牛/流/柳/溜)、口语“两”、同音变调(九→酒)。 // 若不给别名,方言发音会被语法硬扭到“错误的”数字(如 niú 更接近 jiǔ「九」); // 给出别名让解码器输出真实听到的音,前端 DIG 表再映射回数字。 'alias ::= "牛" | "流" | "柳" | "溜" | "两" | "酒"', 'teen ::= "十" unit', 'tens ::= unit "十" unit?', ].join('\n'); const dynGrammarCache = new Map(); function dynGrammar(a, b) { const key = a + 'x' + b; if (dynGrammarCache.has(key)) return dynGrammarCache.get(key); const g = [ 'root ::= prefix? product', `prefix ::= ("${CN[a]}${CN[b]}"${a !== b ? ` | "${CN[b]}${CN[a]}"` : ''}) "得"?`, GRAMMAR_NUM, ].join('\n'); const file = path.join(os.tmpdir(), 'calx-grammar-' + key + '.gbnf'); fs.writeFileSync(file, g); dynGrammarCache.set(key, file); return file; } 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, a, b) { 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.5s 静音 // (关键:短音节如“一/八”若无留白,whisper 常丢字或重复成“88”,补静音可修复) const ff = await run(FFMPEG, ['-y', '-loglevel', 'error', '-i', inFile, '-af', 'adelay=500ms:all=1,apad=pad_dur=0.5', '-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 离线识别:语法约束解码,强制输出成合法数字,杜绝英文/乱码幻听 // 带题目参数时用按题定制的 grammar(前缀锁死为本题因数),进一步压缩解码错配空间 const grammar = (a >= 1 && a <= 9 && b >= 1 && b <= 9) ? dynGrammar(a, b) : (HAS_GRAMMAR ? GRAMMAR : null); const args = ['-m', MODEL, '-l', 'zh', '-nt', '-np', '-mc', '0', '-t', THREADS, '-otxt', '-of', outBase, '-f', wavFile]; if (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; } 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 a = parseInt(url.searchParams.get('a'), 10), b = parseInt(url.searchParams.get('b'), 10); const out = await transcribe(buf, req.headers['content-type'], a, b); 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)'}`); console.log(`提示: 浏览器麦克风需要安全上下文——本机用 http://localhost:${PORT};对外部署请挂在支持 HTTPS 的反向代理后面`); });