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 | 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((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 => { 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 requestStartedAt = Date.now(); const audioDurationMs = Math.round(audio.length / 2 / 16_000 * 1000); console.info(`[Baidu ASR] start bytes=${audio.length} durationMs=${audioDurationMs}`); const text = await recognizeAudio(audio); console.info(`[Baidu ASR] success durationMs=${audioDurationMs} elapsedMs=${Date.now() - requestStartedAt}`); sendJson(res, 200, {text}); } catch (error) { if (error instanceof HttpError) { console.warn(`[Baidu ASR] failed status=${error.statusCode} message=${error.message}`); 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); }, }; }