ai-chatbot/lib/video-canvas/audio-generation.ts

133 lines
5.0 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 "server-only";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { downloadCanvasMedia } from "./download-media";
import {
audioContract,
audioCheckpointSchema,
audioRequestSchema,
buildAudioRequest,
officialAudioBase,
} from "./audio-contract";
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
export async function listCanvasAudioModels() {
return (await getEnabledAiModelsWithProvider()).filter(({ model, provider }) => {
try {
return (
provider.type === "alibaba" &&
!!audioContract(model.modelId) &&
!!officialAudioBase(provider.baseUrl, model.modelId)
);
} catch {
return false;
}
});
}
export async function executeCanvasAudio(
params: unknown,
lifecycle: {
resume?: unknown;
assertActive: () => Promise<void>;
checkpoint: (value: Record<string, unknown>) => Promise<void>;
},
) {
const input = audioRequestSchema.parse(params);
let checkpoint = lifecycle.resume ? audioCheckpointSchema.parse(lifecycle.resume) : undefined;
if (checkpoint && checkpoint.modelRef !== input.modelRef) throw Error("音频记录与原模型不匹配");
if (!checkpoint) {
const item = (await listCanvasAudioModels()).find((x) => x.model.id === input.modelRef);
if (!item) throw Error("音频模型未启用,请先在阿里云和模型设置中开通");
const request = buildAudioRequest(input, item.model.modelId);
await lifecycle.assertActive();
let response: Response;
try {
response = await fetch(
officialAudioBase(item.provider.baseUrl, item.model.modelId) + request.path,
{
method: "POST",
headers: {
Authorization: `Bearer ${item.provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(request.body),
signal: AbortSignal.timeout(input.mode === "music" ? 600000 : 180000),
},
);
} catch {
throw new ProviderTaskUncertainError(
"音频请求连接中断,无法确认是否已受理;请核对服务商记录,系统不会自动重发",
);
}
let data: any;
try {
data = await response.json();
} catch {
throw new ProviderTaskUncertainError("音频响应无法解析,请核对服务商记录,系统不会自动重发");
}
if (!response.ok)
throw Error(
`阿里云音频请求失败(${response.status},${String(data.code || data.error?.code || "UnknownError").slice(0, 120)})${input.mode === "music" ? ",请确认 Fun-Music 邀测权限已开通" : ""}`,
);
const url = data.output?.audio?.url;
if (typeof url !== "string" || !/^https?:\/\//i.test(url))
throw new ProviderTaskUncertainError("音频服务未返回有效文件地址,请核对原请求,勿重复生成");
checkpoint = {
operation: "audio",
url,
format: input.format,
modelRef: item.model.id,
modelId: item.model.modelId,
requestId: data.request_id,
expiresAt:
typeof data.output.audio.expires_at === "number" ? data.output.audio.expires_at : undefined,
};
try {
await lifecycle.assertActive();
await lifecycle.checkpoint({ upstreamAudio: checkpoint });
} catch {
throw new ProviderTaskUncertainError(
"音频已生成,但保存任务记录失败;请核对原请求,系统不会自动重发",
);
}
}
if (checkpoint.expiresAt && checkpoint.expiresAt * 1000 < Date.now())
throw Error("阿里云音频临时地址已过期,无法继续转存");
await lifecycle.assertActive();
const dir = await mkdtemp(path.join(os.tmpdir(), "canvas-audio-"));
try {
const file = path.join(dir, "audio." + checkpoint.format);
await downloadCanvasMedia(checkpoint.url, file);
const bytes = await readFile(file);
if (bytes.length < 12) throw Error("音频内容为空");
const wav =
bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE";
const mp3 =
bytes.toString("ascii", 0, 3) === "ID3" || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0);
if (!(checkpoint.format === "wav" ? wav : mp3)) throw Error("返回文件不是有效音频格式");
await lifecycle.assertActive();
const saved = await uploadFile(
`audio-${crypto.randomUUID()}.${checkpoint.format}`,
Uint8Array.from(bytes).buffer,
);
return {
url: saved.url,
modelRef: checkpoint.modelRef,
modelId: checkpoint.modelId,
metadata: {
storageStatus: "archived",
format: checkpoint.format,
requestId: checkpoint.requestId,
},
};
} catch (error) {
throw new ProviderTaskUncertainError(
`音频已生成,转存暂未完成,将继续转存原文件:${error instanceof Error ? error.message : "存储失败"}`,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
}