202 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createHash } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import { stat, rm } from "node:fs/promises";
import { Transform, Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import path from "node:path";
import { ProviderTaskUncertainError } from "../video-task-lifecycle";
import { audioJobInputSchema, audioJobStatusSchema } from "./audio-contract";
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
export function remoteAudioConfig() {
const value = process.env.SMART_EDIT_AUDIO_URL;
if (!value) return null;
const url = new URL(value),
token = process.env.SMART_EDIT_AUDIO_TOKEN;
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash ||
!token ||
token.length < 32
)
throw Error("远程音频服务配置无效");
return { base: value.replace(/\/$/, ""), token };
}
export async function remoteAudioCapabilities() {
const config = remoteAudioConfig();
if (!config) return null;
try {
const r = await fetch(config.base + "/health", {
headers: { authorization: "Bearer " + config.token },
signal: AbortSignal.timeout(4000),
redirect: "error",
cache: "no-store",
});
if (!r.ok) throw Error();
const data = await r.json();
return {
asr: data.status === "ok" && data.capabilities?.asr === true,
separation:
data.status === "ok" && data.capabilities?.separation === true,
};
} catch {
return { asr: false, separation: false };
}
}
export async function audioFileHash(file: string) {
const h = createHash("sha256");
for await (const chunk of createReadStream(file)) h.update(chunk);
return h.digest("hex");
}
export async function remoteAudio(
operation: "transcribe" | "separate",
file: string,
ownerKey: string,
requestKey: string,
dir: string,
progress: (stage: string) => Promise<void> = async () => {}
) {
const config = remoteAudioConfig();
if (!config) throw Error("远程音频服务未配置");
const plan = audioJobInputSchema.parse({
operation,
bytes: (await stat(file)).size,
sha256: await audioFileHash(file),
});
const tenant = sha(ownerKey),
id = sha(
"audio-v1:" + tenant + ":" + requestKey + ":" + JSON.stringify(plan)
);
const headers = { authorization: "Bearer " + config.token };
async function request(
route: string,
init: RequestInit = {},
timeoutMs?: number
) {
let r: Response;
try {
r = await fetch(config!.base + route, {
...init,
headers: { ...headers, ...init.headers },
signal: AbortSignal.timeout(
timeoutMs || (init.method === "PUT" ? 180000 : 20000)
),
redirect: "error",
cache: "no-store",
});
} catch {
throw new ProviderTaskUncertainError(
"音频服务连接中断,任务已保留,可继续处理同一音频"
);
}
if (
(init.method === "PUT" && r.status === 409) ||
[429, 500, 502, 503, 504].includes(r.status)
)
throw new ProviderTaskUncertainError(
"音频服务繁忙,等待继续处理同一任务"
);
if (!r.ok) throw Error(`音频服务请求失败(${r.status})`);
return r;
}
async function status(r: Response) {
let value: unknown;
try {
value = await r.json();
} catch {
throw new ProviderTaskUncertainError(
"音频服务响应中断,将继续查询同一任务"
);
}
const s = audioJobStatusSchema.parse(value);
if (s.id !== id) throw Error("音频任务编号不匹配");
return s;
}
let current = await status(
await request("/v1/audio-jobs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id, tenant, plan }),
})
);
if (current.status === "awaiting-input") {
await progress("传送音频到算力服务器");
current = await status(
await request("/v1/audio-jobs/" + id + "/input", {
method: "PUT",
headers: {
"content-type": "audio/wav",
"content-length": String(plan.bytes),
},
body: createReadStream(file) as any,
duplex: "half",
} as RequestInit)
);
}
const deadline = Date.now() + 8 * 60000;
while (current.status === "queued" || current.status === "running") {
await progress(current.stage);
if (Date.now() > deadline)
throw new ProviderTaskUncertainError(
"远程音频仍在处理中,任务已保存,稍后继续查询"
);
await new Promise((r) => setTimeout(r, 2000));
current = await status(await request("/v1/audio-jobs/" + id));
}
if (current.status === "failed") throw Error(current.error || "音频处理失败");
if (current.status !== "succeeded" || !current.result)
throw Error("音频服务没有返回有效结果");
const result = current.result;
if (operation === "transcribe") {
if (!result.subtitles) throw Error("音频服务缺少转写结果");
if (
result.subtitles.some(
(s) => s.end > result.durationSeconds * 1e6 + 100000
)
)
throw Error("转写时间戳超出音频范围");
} else {
if (!result.stems) throw Error("音频服务缺少分离结果");
for (const stem of ["vocals", "background"] as const) {
await progress(stem === "vocals" ? "读取人声音轨" : "读取背景声音轨");
const destination = path.join(dir, stem + ".wav"),
temp = destination + ".part";
try {
const r = await request(
"/v1/audio-jobs/" + id + "/" + stem,
{},
180000
);
if (!r.body) throw Error();
let bytes = 0;
const hash = createHash("sha256"),
expected = result.stems[stem];
await pipeline(
Readable.fromWeb(r.body as any),
new Transform({
transform(chunk, _enc, cb) {
bytes += chunk.length;
hash.update(chunk);
cb(bytes > expected.bytes ? Error("音轨超过上限") : null, chunk);
},
}),
createWriteStream(temp, { mode: 0o600 })
);
if (bytes !== expected.bytes || hash.digest("hex") !== expected.sha256)
throw Error("分离音轨校验失败");
const { rename } = await import("node:fs/promises");
await rename(temp, destination);
} catch {
throw new ProviderTaskUncertainError(
"分离音轨读取中断,将继续读取同一结果"
);
} finally {
await rm(temp, { force: true });
}
}
}
return result;
}