feat: 将视频转写重构为纯文字异步语音识别
This commit is contained in:
@@ -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());
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user