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 { 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, format: string, options?: RequestOptions) { const created = await audioTransport(config).postJson>(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>(aiApiUrl(config, `/audio/tasks/${encodeURIComponent(taskId)}`), options); state = asyncAudioPayload(polled); } throw new Error(`异步音频生成超时(任务 ${taskId})`); } function asyncAudioPayload(payload: Record): Record { for (const key of ["data", "result", "output"]) { const nested = payload[key]; if (nested && typeof nested === "object" && !Array.isArray(nested)) return { ...payload, ...(nested as Record) }; } return payload; } function asyncAudioTaskId(payload: Record) { 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) { 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 { 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); if (nested) return nested; } } return ""; } async function downloadAsyncAudio(config: AiConfig, taskId: string, state: Record, 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) { 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).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((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 { 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; }