麦克风安全上下文改由部署方案解决:本机用 localhost, 对外部署挂 HTTPS 反向代理。删除 certs/、gen-cert.sh、 /cert 路由与 HTTPS server 分支,文档同步更新。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
164 lines
8.0 KiB
JavaScript
164 lines
8.0 KiB
JavaScript
#!/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 的反向代理后面`);
|
||
});
|