168 lines
7.9 KiB
TypeScript
168 lines
7.9 KiB
TypeScript
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
|
||
import { createChannelTransport } from "@/services/api/channel-transport";
|
||
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||
import { buildApiUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||
|
||
type RequestOptions = { signal?: AbortSignal };
|
||
|
||
function aiApiUrl(config: AiConfig, path: string) {
|
||
return buildApiUrl(config.baseUrl, path);
|
||
}
|
||
|
||
function audioTransport(config: AiConfig) {
|
||
return createChannelTransport(config, "audio");
|
||
}
|
||
|
||
export async function requestAudioGeneration(config: AiConfig, prompt: string, options?: RequestOptions): Promise<Blob> {
|
||
const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel);
|
||
const model = requestConfig.model.trim();
|
||
assertAudioConfig(requestConfig, model);
|
||
const format = normalizeAudioFormatValue(config.audioFormat);
|
||
const instructions = config.audioInstructions.trim();
|
||
const payload = {
|
||
model,
|
||
input: prompt,
|
||
voice: normalizeAudioVoiceValue(config.audioVoice),
|
||
response_format: format,
|
||
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
|
||
...(instructions ? { instructions } : {}),
|
||
};
|
||
|
||
try {
|
||
if (requestConfig.interfaceType === "async-audio") {
|
||
return await requestAsyncAudioGeneration(requestConfig, payload, format, options);
|
||
}
|
||
const blob = await audioTransport(requestConfig).postBlob(aiApiUrl(requestConfig, "/audio/speech"), payload, { signal: options?.signal });
|
||
await assertAudioBlob(blob);
|
||
return blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||
} catch (error) {
|
||
throw new Error(readAxiosError(error, "音频生成失败"));
|
||
}
|
||
}
|
||
|
||
async function requestAsyncAudioGeneration(config: AiConfig, payload: Record<string, unknown>, format: string, options?: RequestOptions) {
|
||
const created = await audioTransport(config).postJson<Record<string, unknown>>(aiApiUrl(config, "/audio/tasks"), payload, options);
|
||
let state = asyncAudioPayload(created);
|
||
const taskId = asyncAudioTaskId(state);
|
||
if (!taskId) throw new Error("异步音频接口没有返回任务 ID");
|
||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||
if (asyncAudioSucceeded(state)) return downloadAsyncAudio(config, taskId, state, format, options);
|
||
const status = String(state.status || "").toLowerCase();
|
||
if (["failed", "cancelled", "canceled", "expired", "error"].includes(status)) {
|
||
throw new Error(asyncAudioError(state));
|
||
}
|
||
await waitForAudioPoll(options?.signal);
|
||
const polled = await audioTransport(config).get<Record<string, unknown>>(aiApiUrl(config, `/audio/tasks/${encodeURIComponent(taskId)}`), options);
|
||
state = asyncAudioPayload(polled);
|
||
}
|
||
throw new Error(`异步音频生成超时(任务 ${taskId})`);
|
||
}
|
||
|
||
function asyncAudioPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||
for (const key of ["data", "result", "output"]) {
|
||
const nested = payload[key];
|
||
if (nested && typeof nested === "object" && !Array.isArray(nested)) return { ...payload, ...(nested as Record<string, unknown>) };
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function asyncAudioTaskId(payload: Record<string, unknown>) {
|
||
for (const value of [payload.id, payload.task_id, payload.request_id]) {
|
||
if (typeof value === "string" && value.trim()) return value.trim();
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function asyncAudioSucceeded(payload: Record<string, unknown>) {
|
||
const status = String(payload.status || "").toLowerCase();
|
||
return payload.done === true || ["completed", "succeeded", "success", "done"].includes(status) || (!status && Boolean(asyncAudioResultUrl(payload)));
|
||
}
|
||
|
||
function asyncAudioResultUrl(payload: Record<string, unknown>): string {
|
||
for (const key of ["audio_url", "audioUrl", "result_url", "resultUrl", "output_url", "outputUrl", "url", "data"]) {
|
||
const value = payload[key];
|
||
if (typeof value === "string" && (/^https?:\/\//i.test(value) || value.startsWith("data:audio/"))) return value;
|
||
}
|
||
for (const key of ["audio", "data", "result", "output"]) {
|
||
const value = payload[key];
|
||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||
const nested = asyncAudioResultUrl(value as Record<string, unknown>);
|
||
if (nested) return nested;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function downloadAsyncAudio(config: AiConfig, taskId: string, state: Record<string, unknown>, format: string, options?: RequestOptions) {
|
||
const resultUrl = asyncAudioResultUrl(state);
|
||
let blob: Blob;
|
||
if (resultUrl.startsWith("data:audio/")) {
|
||
blob = await (await fetch(resultUrl, { signal: options?.signal })).blob();
|
||
} else if (/^https?:\/\//i.test(resultUrl)) {
|
||
blob = await audioTransport(config).getExternalBlob(resultUrl, undefined, options);
|
||
} else {
|
||
blob = await audioTransport(config).getBlob(aiApiUrl(config, `/audio/tasks/${encodeURIComponent(taskId)}/content`), options);
|
||
}
|
||
await assertAudioBlob(blob);
|
||
return blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||
}
|
||
|
||
function asyncAudioError(payload: Record<string, unknown>) {
|
||
const error = payload.error;
|
||
if (typeof error === "string" && error.trim()) return error;
|
||
if (error && typeof error === "object" && !Array.isArray(error)) {
|
||
const message = (error as Record<string, unknown>).message;
|
||
if (typeof message === "string" && message.trim()) return message;
|
||
}
|
||
return typeof payload.message === "string" && payload.message.trim() ? payload.message : "异步音频生成失败";
|
||
}
|
||
|
||
function waitForAudioPoll(signal?: AbortSignal) {
|
||
return new Promise<void>((resolve, reject) => {
|
||
if (signal?.aborted) {
|
||
reject(new DOMException("Aborted", "AbortError"));
|
||
return;
|
||
}
|
||
const onAbort = () => {
|
||
window.clearTimeout(timer);
|
||
reject(new DOMException("Aborted", "AbortError"));
|
||
};
|
||
const timer = window.setTimeout(() => {
|
||
signal?.removeEventListener("abort", onAbort);
|
||
resolve();
|
||
}, 2500);
|
||
signal?.addEventListener("abort", onAbort, { once: true });
|
||
});
|
||
}
|
||
|
||
export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<UploadedFile> {
|
||
const audio = blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||
return uploadMediaFile(audio, "audio");
|
||
}
|
||
|
||
function assertAudioConfig(config: AiConfig, model: string) {
|
||
if (!model) throw new Error("请先配置音频模型");
|
||
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||
if (config.apiFormat === "gemini") throw new Error("Gemini 调用格式暂不支持音频生成,请使用 OpenAI 格式渠道");
|
||
}
|
||
|
||
async function assertAudioBlob(blob: Blob) {
|
||
const mimeType = blob.type.toLowerCase();
|
||
if (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("text/")) throw new Error(`上游返回了非音频内容:${mimeType}`);
|
||
if (!mimeType.includes("json")) return;
|
||
let payload: { code?: number; msg?: string; error?: { message?: string } };
|
||
try {
|
||
payload = JSON.parse(await blob.text()) as { code?: number; msg?: string; error?: { message?: string } };
|
||
} catch {
|
||
return;
|
||
}
|
||
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "音频生成失败");
|
||
if (payload.error?.message) throw new Error(payload.error.message);
|
||
}
|
||
|
||
function readAxiosError(error: unknown, fallback: string) {
|
||
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
|
||
return error instanceof Error ? error.message : fallback;
|
||
}
|