230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
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 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);
|
||
},
|
||
};
|
||
}
|