160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
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<string, unknown>;
|
|
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<unknown> {
|
|
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<string> {
|
|
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<AsrTask> {
|
|
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<string[]> {
|
|
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));
|
|
}
|