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
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { extractAudio } from "../app/api/media";
test("extract a whole video longer than five minutes as compact mono MP3", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "asr-media-test-"));
try {
const video = path.join(dir, "long.mp4");
execFileSync(process.env.FFMPEG_PATH || "ffmpeg", [
"-v",
"error",
"-f",
"lavfi",
"-i",
"color=c=black:s=16x16:r=1",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=16000",
"-t",
"310",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-c:a",
"aac",
video,
]);
const result = await extractAudio(video, {
format: "mp3",
bitrateKbps: 64,
});
assert.ok(result);
assert.ok(result.buffer.length < 3_000_000);
const audio = path.join(dir, "audio.mp3");
await writeFile(audio, result.buffer);
const probe = JSON.parse(
execFileSync(
process.env.FFPROBE_PATH || "ffprobe",
[
"-v",
"error",
"-show_entries",
"stream=codec_name,sample_rate,channels:format=duration",
"-of",
"json",
audio,
],
{ encoding: "utf8" },
),
);
assert.equal(probe.streams[0].codec_name, "mp3");
assert.equal(probe.streams[0].sample_rate, "16000");
assert.equal(probe.streams[0].channels, 1);
assert.ok(Number(probe.format.duration) >= 310);
assert.ok(Number(probe.format.duration) < 311);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
+12
View File
@@ -0,0 +1,12 @@
// Imported before Prisma so its engine cannot capture the project's live URL.
const database = process.env.TEST_DATABASE_URL;
if (database) {
const url = new URL(database);
if (
!["localhost", "127.0.0.1"].includes(url.hostname) ||
url.pathname !== "/douyin_asr_test"
) {
throw new Error("Only local douyin_asr_test database is permitted");
}
process.env.DATABASE_URL = database;
}
+308
View File
@@ -0,0 +1,308 @@
import "./stt-test-env";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { after, before, beforeEach, describe, test } from "node:test";
import type { NextRequest } from "next/server";
import { GET, POST } from "../app/api/stt/route";
import { prisma } from "../lib/prisma";
import { AsrError } from "../lib/stt/dashscope";
import {
claimTask,
completeTask,
enqueueMissingTranscriptions,
enqueueTranscription,
readTranscription,
} from "../lib/stt/tasks";
import { processTask } from "../lib/stt/worker";
const database = process.env.TEST_DATABASE_URL;
const migration = readFileSync(
"prisma/migrations/20261003000000_plain_transcription/migration.sql",
"utf8",
);
const sql = (source: string) =>
execFileSync("psql", [database ?? "", "-X", "-v", "ON_ERROR_STOP=1", "-q"], {
input: source,
stdio: ["pipe", "pipe", "pipe"],
});
const fakeServices = {
prepareAudio: async () => "https://storage.test/audio.mp3" as string | null,
submitTranscription: async () => "provider-1",
queryTranscription: async () => ({
status: "SUCCEEDED" as const,
resultURL: "https://result.test/file",
}),
downloadTranscript: async () => ["new searchable words"],
};
async function due(videoId: string) {
await prisma.transcriptionTask.update({
where: { videoId },
data: { nextPollAt: new Date(0) },
});
}
async function claim() {
const task = await claimTask();
assert.ok(task);
return task;
}
function request(url: string, body?: unknown): NextRequest {
return Object.assign(
new Request(
url,
body === undefined
? {}
: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
{ nextUrl: new URL(url) },
) as NextRequest;
}
describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
before(async () => {
sql("DROP SCHEMA public CASCADE; CREATE SCHEMA public;");
sql(
readFileSync("prisma/migrations/20251025_baseline/migration.sql", "utf8"),
);
await prisma.author.create({
data: {
sec_uid: "test-author",
uid: "uid",
nickname: "测试作者",
signature: "",
avatar_url: "",
follower_count: 0,
total_favorited: 0,
short_id: "test",
},
});
for (const id of ["historical", "empty-history", "v1", "v2", "v3", "v4"]) {
await prisma.video.create({
data: {
aweme_id: id,
desc: "测试视频",
duration_ms: 1000,
created_at: new Date(),
share_url: "",
digg_count: 0,
comment_count: 0,
share_count: 0,
collect_count: 0,
authorId: "test-author",
tags: [],
video_url: "video.mp4",
},
});
}
sql(
`INSERT INTO "VideoTranscript" (id,"videoId",speech_detected,transcript,"updatedAt") VALUES ('old','historical',true,ARRAY['original searchable words'],NOW()), ('empty','empty-history',false,ARRAY[]::text[],NOW());`,
);
sql(`ALTER TABLE "VideoTranscript" ADD COLUMN content_tags TEXT[] DEFAULT ARRAY[]::TEXT[];
CREATE TEXT SEARCH CONFIGURATION public.zhcfg (COPY = pg_catalog.simple);
CREATE FUNCTION public.vt_update_transcript_zh_tsv() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_vt_transcript_zh_tsv BEFORE INSERT OR UPDATE OF transcript ON "VideoTranscript" FOR EACH ROW EXECUTE FUNCTION public.vt_update_transcript_zh_tsv();`);
sql(migration);
sql(migration); // migration DDL is repeatable, historical text is unchanged
});
beforeEach(async () => {
process.env.DASHSCOPE_API_KEY = "test-key";
delete process.env.DASHSCOPE_BASE_URL;
delete process.env.DASHSCOPE_ASR_MODEL;
await prisma.transcriptionTask.deleteMany();
await prisma.videoTranscript.deleteMany({
where: { videoId: { in: ["v1", "v2", "v3", "v4"] } },
});
});
after(async () => {
await prisma.$disconnect();
});
test("migration preserves historical and empty text and rebuilds search", async () => {
assert.equal(
(await readTranscription("empty-history"))?.status,
"SUCCEEDED",
);
assert.deepEqual((await readTranscription("historical"))?.transcript, [
"original searchable words",
]);
const rows = await prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'original')`;
assert.equal(Number(rows[0].count), 1);
});
test("concurrent enqueue is idempotent; automatic enqueue never retranscribes history", async () => {
await Promise.all(
Array.from({ length: 10 }, () => enqueueTranscription("v1", true)),
);
assert.equal(await prisma.transcriptionTask.count(), 1);
assert.equal(
(await enqueueTranscription("historical"))?.status,
"SUCCEEDED",
);
assert.equal(await prisma.transcriptionTask.count(), 1);
});
test("batch skips history and active tasks and retries failed missing transcripts", async () => {
await enqueueTranscription("v1");
await prisma.transcriptionTask.update({
where: { videoId: "v1" },
data: { status: "FAILED" },
});
await enqueueTranscription("v2");
assert.equal(await enqueueMissingTranscriptions(), 3);
assert.equal(await enqueueMissingTranscriptions(), 0);
assert.equal(await prisma.transcriptionTask.count(), 4);
});
test("admission never exceeds two active provider jobs across workers", async () => {
await enqueueMissingTranscriptions();
await prisma.transcriptionTask.updateMany({
data: { nextPollAt: new Date(0) },
});
const claims = await Promise.all([
claimTask(),
claimTask(),
claimTask(),
claimTask(),
]);
assert.equal(claims.filter(Boolean).length, 2);
assert.equal(
await prisma.transcriptionTask.count({ where: { status: "PROCESSING" } }),
2,
);
});
test("provider ID survives restart; completion saves text and search atomically", async () => {
await enqueueTranscription("v1");
let submits = 0;
const services = {
...fakeServices,
submitTranscription: async () => {
submits++;
return "restart-task";
},
};
await processTask(await claim(), services);
assert.equal(
(
await prisma.transcriptionTask.findUniqueOrThrow({
where: { videoId: "v1" },
})
).providerTaskId,
"restart-task",
);
await due("v1");
await processTask(await claim(), services);
assert.equal(submits, 1);
assert.deepEqual(await readTranscription("v1"), {
status: "SUCCEEDED",
error: null,
transcript: ["new searchable words"],
});
const hits = await prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE "videoId" = 'v1' AND transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'new')`;
assert.equal(Number(hits[0].count), 1);
});
test("failed retranscription retains old text; success replaces text and clears stale search", async () => {
await prisma.videoTranscript.create({
data: { videoId: "v1", transcript: ["oldword"] },
});
await enqueueTranscription("v1", true);
await processTask(await claim(), fakeServices);
await due("v1");
await processTask(await claim(), {
...fakeServices,
queryTranscription: async () => {
throw new AsrError("FILE_403_FORBIDDEN");
},
});
assert.equal((await readTranscription("v1"))?.status, "FAILED");
assert.deepEqual((await readTranscription("v1"))?.transcript, ["oldword"]);
await enqueueTranscription("v1", true);
await processTask(await claim(), fakeServices);
await due("v1");
await processTask(await claim(), {
...fakeServices,
downloadTranscript: async () => [],
});
assert.deepEqual((await readTranscription("v1"))?.transcript, []);
const hits = await prisma.$queryRaw<
{ count: bigint }[]
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE "videoId" = 'v1' AND transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'oldword')`;
assert.equal(Number(hits[0].count), 0);
});
test("crashed/ambiguous submission is never automatically resubmitted", async () => {
await enqueueTranscription("v1");
const task = await claim();
await prisma.transcriptionTask.update({
where: { videoId: "v1" },
data: { submittingAt: new Date(), leaseUntil: new Date(0) },
});
let submits = 0;
await processTask(await claim(), {
...fakeServices,
submitTranscription: async () => {
submits++;
return "bad";
},
});
assert.equal(submits, 0);
assert.equal((await readTranscription("v1"))?.status, "FAILED");
await assert.rejects(completeTask(task, ["stale result"]), /租约/);
assert.equal((await readTranscription("v1"))?.transcript, null);
});
test("temporary query errors back off and keep provider ID", async () => {
await enqueueTranscription("v1");
await processTask(await claim(), fakeServices);
await due("v1");
await processTask(await claim(), {
...fakeServices,
queryTranscription: async () => {
throw new AsrError("网络超时", true);
},
});
const task = await prisma.transcriptionTask.findUniqueOrThrow({
where: { videoId: "v1" },
});
assert.equal(task.status, "PROCESSING");
assert.equal(task.providerTaskId, "provider-1");
assert.ok(task.nextPollAt.getTime() > Date.now());
assert.equal(task.attempts, 1);
});
test("video with no audio track completes with no ASR submission", async () => {
await enqueueTranscription("v1");
await processTask(await claim(), {
...fakeServices,
prepareAudio: async () => null,
submitTranscription: async () => {
throw new Error("must not submit");
},
});
assert.deepEqual(await readTranscription("v1"), {
status: "SUCCEEDED",
error: null,
transcript: [],
});
});
test("missing key is visible; GET has no side effect; POST validates input", async () => {
delete process.env.DASHSCOPE_API_KEY;
const state = await enqueueTranscription("v1");
assert.equal(state?.status, "FAILED");
assert.match(state?.error ?? "", /DASHSCOPE_API_KEY/);
assert.equal(
(await GET(request("http://local/api/stt?awemeId=v2"))).status,
200,
);
assert.equal(await prisma.transcriptionTask.count(), 1);
assert.equal(
(await GET(request("http://local/api/stt?awemeId=missing"))).status,
404,
);
assert.equal((await POST(request("http://local/api/stt", {}))).status, 400);
process.env.DASHSCOPE_API_KEY = "test-key";
assert.equal(
(await POST(request("http://local/api/stt", { awemeId: "v2" }))).status,
202,
);
});
});
+167
View File
@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { createServer, type Server } from "node:http";
import { after, before, test } from "node:test";
import {
AsrError,
downloadTranscript,
extractTranscript,
getAsrConfig,
MAX_BYTES,
MAX_DURATION_MS,
queryTranscription,
submitTranscription,
validateMedia,
} from "../lib/stt/dashscope";
import { transcriptionState } from "../lib/stt/types";
let server: Server;
let base: string;
let response: unknown = { output: { task_id: "task-1" } };
let status = 200;
const calls: {
url?: string;
headers: Record<string, unknown>;
body: string;
}[] = [];
const savedEnv = { ...process.env };
before(async () => {
server = createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
calls.push({ url: req.url, headers: req.headers, body });
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(response));
}).listen(0, "127.0.0.1");
await once(server, "listening");
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
process.env.DASHSCOPE_API_KEY = "test-key";
process.env.DASHSCOPE_BASE_URL = `${base}/api/v1`;
delete process.env.DASHSCOPE_ASR_MODEL;
});
after(async () => {
process.env = savedEnv;
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
test("native ASR submission sends only audio URL and recognition options", async () => {
assert.equal(
await submitTranscription("https://storage.test/audio.mp3"),
"task-1",
);
const call = calls.at(-1);
assert.ok(call);
assert.equal(call.url, "/api/v1/services/audio/asr/transcription");
assert.equal(call.headers.authorization, "Bearer test-key");
assert.equal(call.headers["x-dashscope-async"], "enable");
assert.deepEqual(JSON.parse(call.body), {
model: "qwen3-asr-flash-filetrans",
input: { file_url: "https://storage.test/audio.mp3" },
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
});
});
test("query pending, running, success, failure and unknown", async () => {
for (const state of ["PENDING", "RUNNING"] as const) {
response = { output: { task_status: state } };
assert.deepEqual(await queryTranscription("task-1"), { status: state });
}
response = {
output: {
task_status: "SUCCEEDED",
result: { transcription_url: `${base}/result` },
},
};
assert.deepEqual(await queryTranscription("task-1"), {
status: "SUCCEEDED",
resultURL: `${base}/result`,
});
response = {
output: {
task_status: "FAILED",
code: "FILE_403_FORBIDDEN",
message: "secret-url",
},
};
await assert.rejects(queryTranscription("task-1"), /FILE_403_FORBIDDEN/);
response = { output: { task_status: "UNKNOWN" } };
await assert.rejects(queryTranscription("task-1"), /UNKNOWN/);
});
test("extract only text, retain order, fall back to full text", async () => {
response = {
transcripts: [
{
sentences: [
{ text: " 第一段 ", emotion: "happy", language: "zh" },
{ text: "第二段" },
],
text: "全文",
},
{ sentences: [], text: "备用全文" },
],
};
assert.deepEqual(await downloadTranscript(`${base}/result`), [
"第一段",
"第二段",
"备用全文",
]);
assert.equal(calls.at(-1)?.headers.authorization, undefined);
assert.deepEqual(extractTranscript({ transcripts: [] }), []);
assert.deepEqual(
extractTranscript({ transcripts: [{ text: " ", sentences: [] }] }),
[],
);
assert.throws(() => extractTranscript({}), /transcripts/);
assert.throws(
() =>
extractTranscript({
transcripts: [{ sentences: [{ emotion: "happy" }] }],
}),
/文字/,
);
});
test("limits are inclusive and oversized media fails without truncation", () => {
validateMedia(MAX_DURATION_MS, MAX_BYTES);
assert.throws(() => validateMedia(MAX_DURATION_MS + 1, 1), /12 小时/);
assert.throws(() => validateMedia(1, MAX_BYTES + 1), /2 GB/);
assert.throws(() => validateMedia(1, 0), /为空/);
});
test("HTTP throttling can retry; authentication cannot; no provider error leakage", async () => {
response = { code: "InvalidApiKey", message: "sensitive" };
status = 401;
await assert.rejects(
queryTranscription("task-1"),
(e) =>
e instanceof AsrError && !e.retryable && !e.message.includes("sensitive"),
);
status = 429;
await assert.rejects(
queryTranscription("task-1"),
(e) => e instanceof AsrError && e.retryable,
);
status = 200;
});
test("missing key and incompatible model produce explicit errors", () => {
delete process.env.DASHSCOPE_API_KEY;
assert.throws(getAsrConfig, /DASHSCOPE_API_KEY/);
process.env.DASHSCOPE_API_KEY = "test-key";
process.env.DASHSCOPE_ASR_MODEL = "gemini-2.5-flash";
assert.throws(getAsrConfig, /qwen3-asr-flash-filetrans/);
delete process.env.DASHSCOPE_ASR_MODEL;
});
test("historical empty transcript is complete; failed retry retains old text", () => {
assert.equal(
transcriptionState({ transcript: [] }, null).status,
"SUCCEEDED",
);
assert.deepEqual(
transcriptionState(
{ transcript: ["旧文字"] },
{ status: "FAILED", lastError: "失败" },
),
{
status: "FAILED",
error: "失败",
transcript: ["旧文字"],
},
);
});