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
+77
View File
@@ -0,0 +1,77 @@
import "dotenv/config";
import { writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { prisma } from "../lib/prisma";
import { prepareAudio } from "../lib/stt/audio";
import {
downloadTranscript,
getAsrConfig,
queryTranscription,
submitTranscription,
} from "../lib/stt/dashscope";
async function main() {
const videoId = process.argv[2];
if (!videoId || !/^[A-Za-z0-9_-]+$/.test(videoId))
throw new Error("用法:npx tsx scripts/stt-smoke.ts <视频 ID>");
getAsrConfig();
const video = await prisma.video.findUniqueOrThrow({
where: { aweme_id: videoId },
select: { duration_ms: true },
});
console.log(
JSON.stringify({
videoId,
durationSeconds: video.duration_ms / 1000,
phase: "extracting_audio",
}),
);
const audioURL = await prepareAudio(videoId);
const resultPath = `/tmp/douyin-asr-${videoId}.json`;
if (!audioURL) {
await writeFile(
resultPath,
JSON.stringify({ videoId, transcript: [] }, null, 2),
);
console.log(
JSON.stringify({ phase: "complete", transcript: [], resultPath }),
);
return;
}
const taskId = await submitTranscription(audioURL);
await writeFile(
resultPath,
JSON.stringify({ videoId, taskId, status: "submitted" }, null, 2),
);
console.log(JSON.stringify({ phase: "submitted", taskId }));
for (let attempt = 0; attempt < 90; attempt++) {
await sleep(10_000);
const task = await queryTranscription(taskId);
if (task.status === "SUCCEEDED") {
const transcript = await downloadTranscript(task.resultURL);
await writeFile(
resultPath,
JSON.stringify({ videoId, taskId, transcript }, null, 2),
);
console.log(
JSON.stringify({ phase: "complete", transcript, resultPath }),
);
return;
}
if (attempt % 6 === 0) console.log(JSON.stringify({ phase: task.status }));
}
throw new Error(
`15 分钟内未完成,任务 ID 已保存在 ${resultPath},请查询原任务而非重复提交`,
);
}
main()
.catch((error) => {
// Do not print arbitrary SDK/network errors: they may contain signed URLs.
console.error(
error instanceof Error && !error.name.startsWith("Prisma")
? error.message
: "读取视频或转写失败",
);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+34
View File
@@ -0,0 +1,34 @@
import "dotenv/config";
import { setTimeout as sleep } from "node:timers/promises";
import { prisma } from "../lib/prisma";
import { workerTick } from "../lib/stt/worker";
let stopping = false;
const controller = new AbortController();
for (const signal of ["SIGTERM", "SIGINT"] as const)
process.on(signal, () => {
stopping = true;
controller.abort();
});
async function main() {
console.log("[ASR] 后台执行器已启动,最大并发 2");
try {
while (!stopping) {
try {
await workerTick();
} catch {
console.error("[ASR] 数据库调度失败,稍后重试");
}
if (!stopping)
await sleep(1000, undefined, { signal: controller.signal }).catch(
() => {},
);
}
} finally {
await prisma.$disconnect();
}
}
main().catch(() => {
console.error("[ASR] 执行器退出");
process.exitCode = 1;
});