init: init proj
This commit is contained in:
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