Initial clean project import
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 阿里云 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 || '未知错误' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user