export class AsrError extends Error { constructor( message: string, public retryable = false, public definitiveRejection = false, ) { super(message); } } export const MAX_DURATION_MS = 12 * 60 * 60 * 1000; export const MAX_BYTES = 2 * 1024 * 1024 * 1024; export function validateMedia(durationMs: number, size: number) { if (durationMs > MAX_DURATION_MS) throw new AsrError("音频超过 ASR 的 12 小时时长限制"); if (!Number.isFinite(size) || size <= 0) throw new AsrError("音频文件为空或大小未知"); if (size > MAX_BYTES) throw new AsrError("音频超过 ASR 的 2 GB 大小限制"); } export function getAsrConfig() { const apiKey = process.env.DASHSCOPE_API_KEY?.trim(); if (!apiKey) throw new AsrError("未配置 DASHSCOPE_API_KEY,请配置后重试"); const baseURL = ( process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1" ).replace(/\/+$/, ""); let url: URL; try { url = new URL(baseURL); } catch { throw new AsrError("DASHSCOPE_BASE_URL 不是有效地址"); } if ( !["https:", "http:"].includes(url.protocol) || !url.pathname.endsWith("/api/v1") ) { throw new AsrError("DASHSCOPE_BASE_URL 应为原生 API 地址,以 /api/v1 结尾"); } const model = process.env.DASHSCOPE_ASR_MODEL || "qwen3-asr-flash-filetrans"; if (!/^qwen3-asr-flash-filetrans(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) { throw new AsrError( "DASHSCOPE_ASR_MODEL 必须是 qwen3-asr-flash-filetrans 或其日期版本", ); } return { apiKey, baseURL, model }; } type ObjectValue = Record; function object(value: unknown): ObjectValue { if (!value || typeof value !== "object" || Array.isArray(value)) throw new AsrError("ASR 返回格式异常"); return value as ObjectValue; } function safeCode(value: unknown) { return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value) ? value : "UNKNOWN"; } async function request(url: string, init: RequestInit = {}): Promise { let response: Response; try { response = await fetch(url, { ...init, signal: AbortSignal.timeout(30_000), }); } catch { throw new AsrError("ASR 网络请求失败或超时", true); } if (!response.ok) { const raw = await response.json().catch(() => null); const body = raw && typeof raw === "object" ? (raw as ObjectValue) : {}; throw new AsrError( `ASR HTTP ${response.status} (${safeCode(body.code)})`, response.status === 429 || response.status >= 500, response.status >= 400 && response.status < 500 && response.status !== 429, ); } try { return await response.json(); } catch { throw new AsrError("ASR 返回非 JSON 数据", true); } } export async function submitTranscription(fileURL: string): Promise { const { apiKey, baseURL, model } = getAsrConfig(); const data = object( await request(`${baseURL}/services/audio/asr/transcription`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "X-DashScope-Async": "enable", }, body: JSON.stringify({ model, input: { file_url: fileURL }, parameters: { channel_id: [0], enable_itn: false, enable_words: false }, }), }), ); const output = object(data.output); if (typeof output.task_id !== "string" || !output.task_id) throw new AsrError("ASR 未返回任务 ID,提交结果不确定"); return output.task_id; } export type AsrTask = | { status: "PENDING" | "RUNNING" } | { status: "SUCCEEDED"; resultURL: string }; export async function queryTranscription(taskId: string): Promise { const { apiKey, baseURL } = getAsrConfig(); const data = object( await request(`${baseURL}/tasks/${encodeURIComponent(taskId)}`, { headers: { Authorization: `Bearer ${apiKey}` }, }), ); const output = object(data.output); if (output.task_status === "PENDING" || output.task_status === "RUNNING") return { status: output.task_status }; if (output.task_status === "SUCCEEDED") { const result = object(output.result); if (typeof result.transcription_url !== "string") throw new AsrError("ASR 缺少结果下载地址"); return { status: "SUCCEEDED", resultURL: result.transcription_url }; } throw new AsrError( `ASR 任务失败 (${safeCode(output.code || output.task_status)})`, ); } export function extractTranscript(value: unknown): string[] { const data = object(value); if (!Array.isArray(data.transcripts)) throw new AsrError("ASR 缺少 transcripts,不能作为空文字保存"); return data.transcripts.flatMap((entry: unknown) => { const track = object(entry); if (track.sentences !== undefined && !Array.isArray(track.sentences)) throw new AsrError("ASR 句子格式异常"); const sentences = ((track.sentences ?? []) as unknown[]) .map((sentence) => { const text = object(sentence).text; if (typeof text !== "string") throw new AsrError("ASR 句子缺少文字"); return text.trim(); }) .filter(Boolean); if (sentences.length) return sentences; if (typeof track.text !== "string") throw new AsrError("ASR 音轨缺少文字"); return track.text.trim() ? [track.text.trim()] : []; }); } export async function downloadTranscript(url: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { throw new AsrError("ASR 结果地址无效"); } if (!["http:", "https:"].includes(parsed.protocol)) throw new AsrError("ASR 结果地址协议无效"); // Result storage is not the API host: never forward the API key. return extractTranscript(await request(url)); }