Files
2026-06-19 18:45:55 +08:00

361 lines
12 KiB
TypeScript

/**
* 阿里云 FunAudio 录音文件识别
* 使用HTTP API异步调用,比实时识别更快
* 文档: https://help.aliyun.com/zh/model-studio/funauidio-asr-recorded-speech-recognition-python-sdk
*/
import axios from 'axios';
import logger from '../log/main';
export interface TranscriptionConfig {
apiKey: string;
model?: string; // 默认 fun-asr
}
export interface TranscriptionSentence {
text: string;
beginTime: number;
endTime: number;
words?: Array<{
text: string;
beginTime: number;
endTime: number;
}>;
}
export interface TranscriptionResult {
success: boolean;
text?: string;
sentences?: TranscriptionSentence[];
error?: string;
taskId?: string;
}
const BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
/**
* 验证 API Key 是否有效
* @returns { valid: boolean, error?: string, models?: string[] }
*/
async function validateApiKey(apiKey: string): Promise<{ valid: boolean; error?: string; models?: string[] }> {
try {
logger.info('[FunAudio] 验证 API Key...');
const response = await axios.get(
`${BASE_URL}/services/audio/asr/models`,
{
headers: {
'Authorization': `Bearer ${apiKey}`
},
timeout: 10000
}
);
if (response.status === 200) {
const models = response.data?.data?.map((m: any) => m.model_id) || [];
logger.info('[FunAudio] ✅ API Key 有效', { availableModels: models });
return { valid: true, models };
}
return { valid: false, error: 'API 响应异常' };
} catch (error: any) {
logger.error('[FunAudio] ❌ API Key 验证失败', {
status: error.response?.status,
data: error.response?.data,
message: error.message
});
return {
valid: false,
error: error.response?.data?.message || error.message || 'API Key 无效'
};
}
}
/**
* 提交异步转写任务
*/
async function submitTranscriptionTask(
fileUrl: string, // 改为单数:只接受单个URL
config: TranscriptionConfig
): Promise<{ success: boolean; taskId?: string; error?: string }> {
try {
const model = config.model || 'qwen3-asr-flash-filetrans'; // 🔧 修复:默认使用 qwen3-asr-flash-filetrans 模型
logger.info('[FunAudio] 提交转写任务', { fileUrl, model });
// 🔧 不同模型使用不同的参数格式
const inputParam = model === 'qwen3-asr-flash-filetrans'
? { file_url: fileUrl } // qwen3 模型使用 file_url(单数)
: { file_urls: [fileUrl] }; // fun-asr 模型使用 file_urls(数组)
const response = await axios.post(
`${BASE_URL}/services/audio/asr/transcription`,
{
model: model,
input: inputParam,
parameters: {
channel_id: [0], // 音频通道ID
enable_itn: false // 是否启用逆文本归一化
}
},
{
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable' // 异步调用
},
timeout: 30000
}
);
if (response.data?.output?.task_id) {
logger.info('[FunAudio] 任务提交成功', { taskId: response.data.output.task_id });
return { success: true, taskId: response.data.output.task_id };
} else {
logger.error('[FunAudio] 任务提交失败', response.data);
return { success: false, error: response.data?.message || '任务提交失败' };
}
} catch (error: any) {
// 🔧 增强错误日志:记录完整的请求和响应信息
logger.error('[FunAudio] 任务提交异常', {
// HTTP 状态信息
status: error.response?.status,
statusText: error.response?.statusText,
// 完整的错误响应体(这是关键!)
responseData: error.response?.data,
// 请求信息(用于调试)
requestUrl: error.config?.url,
requestMethod: error.config?.method,
requestData: error.config?.data,
requestHeaders: {
Authorization: error.config?.headers?.Authorization ? '(已隐藏)' : undefined,
'X-DashScope-Async': error.config?.headers?.['X-DashScope-Async']
},
// 原始错误信息
errorMessage: error.message,
errorStack: error.stack
});
// 尝试从响应中提取更详细的错误信息
const responseData = error.response?.data;
let errorMsg = '未知错误';
if (responseData) {
// 阿里云可能返回的错误格式
errorMsg = responseData.message
|| responseData.error_message
|| responseData.msg
|| JSON.stringify(responseData);
} else {
errorMsg = error.message || '未知错误';
}
return { success: false, error: errorMsg };
}
}
/**
* 查询任务状态
*/
async function queryTaskStatus(
taskId: string,
apiKey: string
): Promise<{ status: string; output?: any; error?: string; code?: string }> {
try {
const response = await axios.get(
`${BASE_URL}/tasks/${taskId}`,
{
headers: {
'Authorization': `Bearer ${apiKey}`
},
timeout: 30000
}
);
// 🔧 添加完整响应日志(已注释以减少日志)
// logger.info('[FunAudio] 任务查询响应', {
// taskId,
// responseStatus: response.status,
// responseData: JSON.stringify(response.data, null, 2)
// });
const status = response.data?.output?.task_status || 'UNKNOWN';
const errorMessage = response.data?.output?.message || response.data?.message;
const errorCode = response.data?.output?.code || response.data?.code;
return {
status,
output: response.data?.output,
error: errorMessage,
code: errorCode
};
} catch (error: any) {
logger.error('[FunAudio] 查询任务状态异常', error);
return { status: 'ERROR', error: error.message };
}
}
/**
* 下载并解析转写结果
*/
async function downloadTranscriptionResult(resultUrl: string): Promise<any> {
try {
const response = await axios.get(resultUrl, { timeout: 30000 });
return response.data;
} catch (error: any) {
logger.error('[FunAudio] 下载结果失败', error);
return null;
}
}
/**
* 等待任务完成并获取结果
*/
async function waitForTaskCompletion(
taskId: string,
apiKey: string,
maxWaitSeconds: number = 300
): Promise<{ success: boolean; results?: any[]; error?: string }> {
const startTime = Date.now();
const pollInterval = 2000; // 2秒轮询一次
while ((Date.now() - startTime) < maxWaitSeconds * 1000) {
const { status, output, error } = await queryTaskStatus(taskId, apiKey);
// logger.info('[FunAudio] 任务状态', { taskId, status }); // 注释以减少日志
if (status === 'SUCCEEDED') {
// 任务成功,获取结果
logger.info('[FunAudio] 任务成功,获取结果', { output: JSON.stringify(output, null, 2) });
const result = output?.result; // 🔧 修复:使用单数 result
const parsedResults = [];
if (result?.transcription_url) {
const transcription = await downloadTranscriptionResult(result.transcription_url);
if (transcription) {
parsedResults.push(transcription);
}
}
logger.info('[FunAudio] 解析结果完成', { parsedResultsCount: parsedResults.length });
return { success: true, results: parsedResults };
}
if (status === 'FAILED') {
logger.error('[FunAudio] ❌ 任务失败详情', {
taskId,
status,
error,
output: JSON.stringify(output, null, 2)
});
return { success: false, error: error || '任务失败' };
}
// 等待后继续轮询
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
return { success: false, error: '任务超时' };
}
/**
* 识别录音文件(主接口)
* @param fileUrl 音频文件的公网URL
* @param config 配置
*/
export async function recognizeRecordedAudio(
fileUrl: string,
config: TranscriptionConfig
): Promise<TranscriptionResult> {
try {
logger.info('[FunAudio] 开始录音识别', { fileUrl, model: config.model });
// 0. 验证 API Key(暂时禁用,因为阿里云不支持该端点)
// const validation = await validateApiKey(config.apiKey);
// if (!validation.valid) {
// logger.error('[FunAudio] ❌ API Key 验证失败,无法继续', { error: validation.error });
// return { success: false, error: `API Key 无效: ${validation.error}` };
// }
// 1. 提交任务
const submitResult = await submitTranscriptionTask(fileUrl, config); // 传递单个URL,不再使用数组
if (!submitResult.success || !submitResult.taskId) {
return { success: false, error: submitResult.error || '任务提交失败' };
}
// 2. 等待任务完成
const waitResult = await waitForTaskCompletion(submitResult.taskId, config.apiKey, 300);
if (!waitResult.success || !waitResult.results?.length) {
return { success: false, error: waitResult.error || '未获取到识别结果', taskId: submitResult.taskId };
}
// 3. 解析结果
const transcription = waitResult.results[0];
const sentences: TranscriptionSentence[] = [];
let fullText = '';
if (transcription?.transcripts) {
for (const transcript of transcription.transcripts) {
if (transcript.sentences) {
for (const sentence of transcript.sentences) {
sentences.push({
text: sentence.text || '',
beginTime: sentence.begin_time || 0,
endTime: sentence.end_time || 0,
words: sentence.words?.map((w: any) => ({
text: w.text || '',
beginTime: w.begin_time || 0,
endTime: w.end_time || 0
}))
});
}
}
if (transcript.text) {
fullText += transcript.text;
}
}
}
logger.info('[FunAudio] ✅ 识别完成', {
sentenceCount: sentences.length,
textLength: fullText.length
});
// 🔧 修复:去除字幕时间轴重叠
// 确保上一条字幕的 endTime 不会晚于下一条字幕的 beginTime
const MIN_GAP_MS = 50; // 最小间隔 50 毫秒
for (let i = 0; i < sentences.length - 1; i++) {
const current = sentences[i];
const next = sentences[i + 1];
if (current.endTime >= next.beginTime) {
const oldEndTime = current.endTime;
// 将当前字幕的结束时间调整为下一条字幕开始时间之前
current.endTime = Math.max(current.beginTime + MIN_GAP_MS, next.beginTime - MIN_GAP_MS);
// logger.info('[FunAudio] 修复时间轴重叠', { // 注释以减少日志
// index: i,
// text: current.text.substring(0, 20) + '...',
// oldEndTime,
// newEndTime: current.endTime,
// nextBeginTime: next.beginTime
// });
}
}
return {
success: true,
text: fullText,
sentences,
taskId: submitResult.taskId
};
} catch (error: any) {
logger.error('[FunAudio] 识别异常', error);
return { success: false, error: error.message || '未知错误' };
}
}