shuziren/server/baiduAsrProxy.ts
2026-10-20 16:32:45 +08:00

225 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {IncomingMessage, ServerResponse} from 'node:http';
import type {Plugin} from 'vite';
const BAIDU_APP_ID = '8077750';
const BAIDU_API_KEY = '7TTNPHbh3bBuKY5gX7vNS2Mp';
const BAIDU_SECRET_KEY = 'jjvoStfvqGlvlp06P0zvuQynSGRJn9Xq';
const BAIDU_TOKEN_URL = 'https://aip.baidubce.com/oauth/2.0/token';
const BAIDU_ASR_URL = 'https://vop.baidu.com/pro_api';
const MAX_AUDIO_BYTES = 2 * 1024 * 1024;
const MIN_AUDIO_BYTES = 3_200;
const REQUEST_TIMEOUT_MS = 20_000;
type BaiduTokenResponse = {
access_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};
type BaiduAsrResponse = {
err_no?: number;
err_msg?: string;
result?: string[];
};
let cachedToken = '';
let cachedTokenExpiresAt = 0;
let pendingTokenRequest: Promise<string> | null = null;
class HttpError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}
const sendJson = (res: ServerResponse, statusCode: number, payload: object) => {
if (res.writableEnded || res.destroyed) return;
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(payload));
};
const readAudioBody = (req: IncomingMessage) => new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
let tooLarge = false;
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_AUDIO_BYTES) {
tooLarge = true;
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (tooLarge) {
reject(new HttpError(413, '录音超过60秒,请缩短问题后重试'));
return;
}
const audio = Buffer.concat(chunks);
if (audio.length < MIN_AUDIO_BYTES) {
reject(new HttpError(400, '录音时间太短,请重新说一遍'));
return;
}
resolve(audio);
});
req.on('error', () => reject(new HttpError(400, '读取录音失败')));
});
const fetchWithTimeout = async (url: string, init: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {...init, signal: controller.signal});
} finally {
clearTimeout(timeoutId);
}
};
const requestAccessToken = async () => {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: BAIDU_API_KEY,
client_secret: BAIDU_SECRET_KEY,
});
let response: Response;
try {
response = await fetchWithTimeout(BAIDU_TOKEN_URL, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body,
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new HttpError(504, '百度语音鉴权超时,请稍后重试');
}
throw new HttpError(502, '无法连接百度语音鉴权服务');
}
const data = await response.json().catch(() => ({})) as BaiduTokenResponse;
if (!response.ok || !data.access_token) {
console.error('Baidu token request failed:', data.error, data.error_description);
throw new HttpError(502, '百度语音应用鉴权失败,请检查API Key和Secret Key');
}
cachedToken = data.access_token;
const expiresInSeconds = Math.max(300, Number(data.expires_in) || 2_592_000);
cachedTokenExpiresAt = Date.now() + Math.max(60, expiresInSeconds - 300) * 1000;
return cachedToken;
};
const getAccessToken = async (forceRefresh = false) => {
if (!forceRefresh && cachedToken && Date.now() < cachedTokenExpiresAt) return cachedToken;
if (!forceRefresh && pendingTokenRequest) return pendingTokenRequest;
pendingTokenRequest = requestAccessToken().finally(() => {
pendingTokenRequest = null;
});
return pendingTokenRequest;
};
const mapBaiduError = (data: BaiduAsrResponse) => {
switch (data.err_no) {
case 3301:
return new HttpError(422, '没有识别到清晰语音,请靠近麦克风重新说一遍');
case 3302:
case 110:
case 111:
return new HttpError(502, '百度语音鉴权已失效,请重新获取凭证');
case 3303:
return new HttpError(503, '百度语音服务繁忙,请稍后重试');
case 3304:
return new HttpError(429, '语音识别请求过于频繁,请稍后重试');
case 3305:
return new HttpError(402, '百度语音识别额度已用完,请购买资源包');
case 3310:
return new HttpError(413, '录音超过60秒,请缩短问题后重试');
case 3311:
return new HttpError(400, '录音格式不正确,请重新录制');
default:
return new HttpError(502, `百度语音识别失败(${data.err_no ?? '未知错误'})`);
}
};
const recognizeAudio = async (audio: Buffer, forceRefresh = false): Promise<string> => {
const token = await getAccessToken(forceRefresh);
const query = new URLSearchParams({
cuid: `digital-human-${BAIDU_APP_ID}`,
token,
dev_pid: '80001',
});
let response: Response;
try {
response = await fetchWithTimeout(`${BAIDU_ASR_URL}?${query.toString()}`, {
method: 'POST',
headers: {
'Content-Type': 'audio/pcm;rate=16000',
'Content-Length': String(audio.length),
},
body: audio,
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new HttpError(504, '百度语音识别超时,请稍后重试');
}
throw new HttpError(502, '无法连接百度语音识别服务');
}
const data = await response.json().catch(() => ({})) as BaiduAsrResponse;
if ((data.err_no === 110 || data.err_no === 111 || data.err_no === 3302) && !forceRefresh) {
cachedToken = '';
cachedTokenExpiresAt = 0;
return recognizeAudio(audio, true);
}
if (!response.ok || data.err_no !== 0) throw mapBaiduError(data);
const text = data.result?.[0]?.trim() || '';
if (!text) throw new HttpError(422, '没有识别到清晰语音,请重新说一遍');
return text;
};
export const createBaiduAsrMiddleware = () => async (
req: IncomingMessage,
res: ServerResponse,
) => {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
sendJson(res, 405, {error: '仅支持 POST 请求'});
return;
}
if (!req.headers['content-type']?.toLowerCase().startsWith('audio/pcm')) {
sendJson(res, 415, {error: '录音格式必须是16kHz单声道PCM'});
return;
}
try {
const audio = await readAudioBody(req);
const text = await recognizeAudio(audio);
sendJson(res, 200, {text});
} catch (error) {
if (error instanceof HttpError) {
sendJson(res, error.statusCode, {error: error.message});
return;
}
console.error('Baidu ASR request failed:', error);
sendJson(res, 500, {error: '服务端处理语音失败'});
}
};
export default function baiduAsrProxy(): Plugin {
const middleware = createBaiduAsrMiddleware();
return {
name: 'baidu-asr-server-proxy',
configureServer(server) {
server.middlewares.use('/api/baidu/asr', middleware);
},
configurePreviewServer(server) {
server.middlewares.use('/api/baidu/asr', middleware);
},
};
}