feat: 将视频转写重构为纯文字异步语音识别

This commit is contained in:
2026-10-03 21:58:06 +08:00
parent 040273586b
commit 6f0e38054b
34 changed files with 2283 additions and 1771 deletions
+159
View File
@@ -0,0 +1,159 @@
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));
}