feat(audio): add audio generation capabilities with configurable settings and UI enhancements

This commit is contained in:
HouYunFei
2026-06-02 17:50:36 +08:00
parent 38ec654748
commit 8f19a18c35
14 changed files with 528 additions and 18 deletions
+91
View File
@@ -0,0 +1,91 @@
import axios from "axios";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
"Content-Type": "application/json",
}
: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
}
export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise<Blob> {
const model = (config.model || config.audioModel).trim();
assertAudioConfig(config, model);
const format = normalizeAudioFormatValue(config.audioFormat);
const instructions = config.audioInstructions.trim();
try {
const response = await axios.post<Blob>(
aiApiUrl(config, "/audio/speech"),
{
model,
input: prompt,
voice: normalizeAudioVoiceValue(config.audioVoice),
response_format: format,
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
...(instructions ? { instructions } : {}),
},
{ headers: aiHeaders(config), responseType: "blob" },
);
await assertAudioBlob(response.data);
refreshRemoteUser(config);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
} catch (error) {
throw new Error(readAxiosError(error, "音频生成失败"));
}
}
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.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
}
async function assertAudioBlob(blob: Blob) {
if (!blob.type.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 (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || statusMessage(error.response?.status, fallback);
}
return error instanceof Error ? error.message : fallback;
}
function statusMessage(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}${status}` : fallback;
}