init: init proj

This commit is contained in:
Kid
2026-07-18 16:48:41 +08:00
commit d975859286
12 changed files with 1204 additions and 0 deletions

156
server.js Normal file
View 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)'}`);
});
// ---- HTTPSiOS 用麦克风必需)----
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); }