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