fix(media): 音频任务 - 支持异步 Provider 协议与结果校验 (#146)

Closes #146
This commit is contained in:
ddCat
2026-08-05 19:38:04 +08:00
parent 0304d8774c
commit d0f4d38c6a
7 changed files with 346 additions and 20 deletions

View File

@@ -189,7 +189,7 @@ func interfaceAllowsProxyPath(interfaceType model.ChannelInterfaceType, requestP
return requestPath == "/images/generations"
case model.ChannelInterfaceOpenAIAudio:
return requestPath == "/audio/speech"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
case model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
return false
default:
return true

View File

@@ -65,6 +65,7 @@ const (
ChannelInterfaceVolcengineArkImage ChannelInterfaceType = "volcengine-ark-image"
ChannelInterfaceVolcengineJiMengImage ChannelInterfaceType = "volcengine-jimeng-image"
ChannelInterfaceOpenAIAudio ChannelInterfaceType = "openai-audio"
ChannelInterfaceAsyncAudio ChannelInterfaceType = "async-audio"
ChannelInterfaceNewAPIVideo ChannelInterfaceType = "newapi"
ChannelInterfaceNewAPIChannel1 ChannelInterfaceType = "newapi-channel-1"
ChannelInterfaceNewAPIChannel2 ChannelInterfaceType = "newapi-channel-2"

View File

@@ -733,7 +733,7 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
func validChannelInterfaceType(value model.ChannelInterfaceType) bool {
switch value {
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
return true
default:
return false

View File

@@ -408,7 +408,7 @@ func capabilityForProtocol(protocol model.ChannelInterfaceType) string {
switch protocol {
case model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage:
return "image"
case model.ChannelInterfaceOpenAIAudio:
case model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio:
return "audio"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
return "video"

View File

@@ -735,13 +735,220 @@ func runAudioTask(ctx context.Context, input canvasGenerationInput) (map[string]
if input.Config.AudioInstructions != "" {
body["instructions"] = input.Config.AudioInstructions
}
if input.Config.InterfaceType == string(model.ChannelInterfaceAsyncAudio) {
return runAsyncAudioTask(ctx, input, body, format)
}
data, mimeType, err := postBinary(ctx, input.Config, "/audio/speech", body)
if err != nil {
return nil, err
}
mimeType, err = validateGeneratedAudio(mimeType, data, format)
if err != nil {
return nil, err
}
return map[string]interface{}{"mode": "audio", "audio": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "format": format}}, nil
}
func runAsyncAudioTask(ctx context.Context, input canvasGenerationInput, body map[string]interface{}, format string) (map[string]interface{}, error) {
id := resumedProviderRequestID(ctx)
var state map[string]interface{}
if id == "" {
if err := postJSON(ctx, input.Config, "/audio/tasks", body, &state); err != nil {
return nil, err
}
state = asyncAudioPayload(state)
id = firstNonEmptyString(stringField(state, "id"), stringField(state, "task_id"), stringField(state, "request_id"))
if id == "" {
return nil, errors.New("异步音频接口没有返回任务 ID")
}
if asyncAudioSucceeded(state) {
return asyncAudioResult(ctx, input.Config, id, state, format)
}
}
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
state = map[string]interface{}{}
pollCtx := withProviderRequestKind(ctx, "poll")
if err := getJSON(pollCtx, input.Config, "/audio/tasks/"+url.PathEscape(id), &state); err != nil {
return nil, err
}
state = asyncAudioPayload(state)
if asyncAudioSucceeded(state) {
return asyncAudioResult(ctx, input.Config, id, state, format)
}
status := strings.ToLower(strings.TrimSpace(stringField(state, "status")))
if status == "failed" || status == "cancelled" || status == "canceled" || status == "expired" || status == "error" {
return nil, fmt.Errorf("异步音频生成失败(任务 %s):%s", id, asyncAudioErrorMessage(state))
}
if err := sleepContext(ctx, 2500*time.Millisecond); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("异步音频生成超时(任务 %s)", id)
}
func asyncAudioPayload(payload map[string]interface{}) map[string]interface{} {
for _, key := range []string{"data", "result", "output"} {
if nested, ok := payload[key].(map[string]interface{}); ok {
for parentKey, parentValue := range payload {
if parentKey == "data" || parentKey == "result" || parentKey == "output" {
continue
}
if _, exists := nested[parentKey]; !exists {
nested[parentKey] = parentValue
}
}
return nested
}
}
return payload
}
func asyncAudioSucceeded(state map[string]interface{}) bool {
status := strings.ToLower(strings.TrimSpace(stringField(state, "status")))
done, _ := state["done"].(bool)
return done || status == "completed" || status == "succeeded" || status == "success" || status == "done" || (status == "" && asyncAudioResultURL(state) != "")
}
func asyncAudioResult(ctx context.Context, config providerConfig, id string, state map[string]interface{}, format string) (map[string]interface{}, error) {
resultURL := asyncAudioResultURL(state)
var data []byte
var mimeType string
var err error
if strings.HasPrefix(resultURL, "data:") {
mimeType, data, err = decodeProviderDataURL(resultURL)
if err == nil {
limit, limitErr := providerGeneratedFileLimit(ctx)
if limitErr != nil {
err = limitErr
} else if int64(len(data)) > limit {
err = fmt.Errorf("异步音频结果超过 %s 限制", formatStorageLimit(limit))
}
}
} else if isPublicMediaURL(resultURL) {
data, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "download"), resultURL)
} else {
data, mimeType, err = getBinary(withProviderRequestKind(ctx, "download"), config, "/audio/tasks/"+url.PathEscape(id)+"/content")
}
if err != nil {
return nil, fmt.Errorf("异步音频结果下载失败(任务 %s):%w", id, err)
}
mimeType, err = validateGeneratedAudio(mimeType, data, format)
if err != nil {
return nil, fmt.Errorf("异步音频结果无效(任务 %s):%w", id, err)
}
return map[string]interface{}{"mode": "audio", "audio": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "format": format}}, nil
}
func asyncAudioResultURL(state map[string]interface{}) string {
for _, key := range []string{"audio_url", "audioUrl", "result_url", "resultUrl", "output_url", "outputUrl", "url", "data"} {
if value := strings.TrimSpace(stringField(state, key)); strings.HasPrefix(value, "data:") || isPublicMediaURL(value) {
return value
}
}
for _, key := range []string{"audio", "data", "result", "output"} {
if nested, ok := state[key].(map[string]interface{}); ok {
if value := asyncAudioResultURL(nested); value != "" {
return value
}
}
}
return ""
}
func asyncAudioErrorMessage(state map[string]interface{}) string {
_, message := providerFailureDetails(state)
return defaultString(message, firstNonEmptyString(stringField(state, "message"), "上游返回失败状态"))
}
func decodeProviderDataURL(value string) (string, []byte, error) {
header, encoded, ok := strings.Cut(value, ",")
if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(strings.ToLower(header), ";base64") {
return "", nil, errors.New("音频 data URL 格式无效")
}
mimeType := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64")
data, err := base64.StdEncoding.DecodeString(encoded)
return mimeType, data, err
}
func providerGeneratedFileLimit(ctx context.Context) (int64, error) {
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil {
return maxProviderResponseBytes, nil
}
policy, err := metadata.Service.RuntimePolicy()
if err != nil {
return 0, fmt.Errorf("读取生成资源限制失败:%w", err)
}
return megabytes(policy.Resource.GeneratedFileMB), nil
}
func validateGeneratedAudio(declared string, data []byte, format string) (string, error) {
if len(data) == 0 {
return "", errors.New("音频内容为空")
}
detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
if strings.Contains(detected, "json") || strings.HasPrefix(detected, "text/") || strings.HasPrefix(detected, "image/") || strings.HasPrefix(detected, "video/") {
return "", fmt.Errorf("上游返回了非音频内容:%s", detected)
}
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(declared, ";")[0]))
resolved := ""
if strings.HasPrefix(mimeType, "audio/") {
resolved = mimeType
} else if strings.HasPrefix(detected, "audio/") {
resolved = detected
} else if fallback := audioFormatMimeType(format); fallback != "" && (mimeType == "" || mimeType == "application/octet-stream") {
resolved = fallback
}
if resolved == "" {
return "", fmt.Errorf("上游响应类型不是音频:%s", defaultString(mimeType, detected))
}
if !audioSignatureMatches(resolved, data) {
return "", fmt.Errorf("音频内容与格式不匹配:%s", resolved)
}
return resolved, nil
}
func audioSignatureMatches(mimeType string, data []byte) bool {
if strings.Contains(mimeType, "pcm") || mimeType == "audio/l16" {
return len(data) > 0
}
if strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3") {
return bytes.HasPrefix(data, []byte("ID3")) || (len(data) >= 2 && data[0] == 0xff && data[1]&0xe0 == 0xe0)
}
if strings.Contains(mimeType, "wav") || strings.Contains(mimeType, "wave") {
return len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WAVE"))
}
if strings.Contains(mimeType, "opus") || strings.Contains(mimeType, "ogg") {
return bytes.HasPrefix(data, []byte("OggS"))
}
if strings.Contains(mimeType, "flac") {
return bytes.HasPrefix(data, []byte("fLaC"))
}
if strings.Contains(mimeType, "aac") {
return bytes.HasPrefix(data, []byte("ADIF")) || (len(data) >= 2 && data[0] == 0xff && data[1]&0xf0 == 0xf0)
}
return false
}
func audioFormatMimeType(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "wav":
return "audio/wav"
case "opus":
return "audio/opus"
case "aac":
return "audio/aac"
case "flac":
return "audio/flac"
case "pcm":
return "audio/pcm"
case "mp3":
return "audio/mpeg"
default:
return ""
}
}
func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengVideo) {
return runVolcengineJiMengVideoTask(ctx, input)
@@ -1413,7 +1620,7 @@ func validateGenerationInterface(mode string, interfaceType string) error {
"text": {"chat-completion": true, "openai-response": true},
"image": {"openai-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true},
"video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true},
"audio": {"openai-audio": true},
"audio": {"openai-audio": true, "async-audio": true},
}
if allowed[mode] != nil && !allowed[mode][interfaceType] {
return fmt.Errorf("接口类型 %s 不支持%s生成", interfaceType, mode)

View File

@@ -5,6 +5,7 @@ export type ModelProtocol =
| "volcengine-ark-image"
| "volcengine-jimeng-image"
| "openai-audio"
| "async-audio"
| "newapi"
| "newapi-channel-1"
| "newapi-channel-2"
@@ -32,11 +33,28 @@ export const MODEL_PROTOCOLS: ModelProtocolDefinition[] = [
{ value: "volcengine-ark-image", label: "火山方舟图片", capability: "image", create: "POST /api/v3/images/generations", contentType: "application/json", media: "文生图与 image 参考图,不支持蒙版" },
{ value: "volcengine-jimeng-image", label: "即梦官方图片", capability: "image", create: "POST CVSync2AsyncSubmitTask", poll: "POST CVSync2AsyncGetResult", contentType: "application/json + AK/SK 签名", media: "0-14 张参考图,模型标识填写 req_key" },
{ value: "openai-audio", label: "OpenAI Audio", capability: "audio", create: "POST /v1/audio/speech", contentType: "application/json", media: "文本转语音" },
{ value: "async-audio", label: "异步音频任务", capability: "audio", create: "POST /v1/audio/tasks", poll: "GET /v1/audio/tasks/{task_id}", contentType: "application/json", media: "语音、音效与音乐生成" },
{ value: "newapi", label: "OpenAI / NewAPI Videos", capability: "video", create: "POST /v1/videos", poll: "GET /v1/videos/{task_id}", contentType: "multipart/form-data", media: "input_reference[] 参考图" },
{ value: "newapi-channel-1", label: "NewAPI 媒体任务", capability: "video", create: "POST /v1/videos", poll: "GET /v1/videos/{task_id}", contentType: "application/json", media: "图片、视频、音频公网 URL" },
{ value: "newapi-channel-2", label: "NewAPI Video Generations", capability: "video", create: "POST /v1/video/generations", poll: "GET /v1/video/generations/{task_id}", contentType: "application/json", media: "image_urls(首帧、尾帧、其他参考图) / video_urls / audio_urls" },
{
value: "newapi-channel-2",
label: "NewAPI Video Generations",
capability: "video",
create: "POST /v1/video/generations",
poll: "GET /v1/video/generations/{task_id}",
contentType: "application/json",
media: "image_urls(首帧、尾帧、其他参考图) / video_urls / audio_urls",
},
{ value: "xai-video", label: "xAI 官方视频", capability: "video", create: "POST /v1/videos/generations", poll: "GET /v1/videos/{request_id}", contentType: "application/json", media: "单张起始图" },
{ value: "volcengine-ark-video", label: "火山方舟视频", capability: "video", create: "POST /api/v3/contents/generations/tasks", poll: "GET /api/v3/contents/generations/tasks/{task_id}", contentType: "application/json", media: "图片、视频、音频参考素材" },
{
value: "volcengine-ark-video",
label: "火山方舟视频",
capability: "video",
create: "POST /api/v3/contents/generations/tasks",
poll: "GET /api/v3/contents/generations/tasks/{task_id}",
contentType: "application/json",
media: "图片、视频、音频参考素材",
},
{ value: "volcengine-jimeng-video", label: "即梦官方视频", capability: "video", create: "POST CVSync2AsyncSubmitTask", poll: "POST CVSync2AsyncGetResult", contentType: "application/json + AK/SK 签名", media: "文本或一张首帧图,模型标识填写 req_key" },
{ value: "gemini-veo", label: "Gemini Veo", capability: "video", create: "POST /v1beta/models/{model}:predictLongRunning", poll: "GET /v1beta/{operation_name}", contentType: "application/json", media: "文本与单张起始图" },
];
@@ -73,7 +91,7 @@ export function modelProtocolSummary(value?: string) {
}
export function normalizeModelProtocol(value: unknown): ModelProtocol | undefined {
return typeof value === "string" && MODEL_PROTOCOLS.some((item) => item.value === value) ? value as ModelProtocol : undefined;
return typeof value === "string" && MODEL_PROTOCOLS.some((item) => item.value === value) ? (value as ModelProtocol) : undefined;
}
function protocolOptions(capability: ProtocolCapability) {

View File

@@ -26,21 +26,21 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
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 request = channelRequest(requestConfig, aiApiUrl(requestConfig, "/audio/speech"), aiHeaders(requestConfig));
const response = await axios.post<Blob>(
request.url,
{
model,
input: prompt,
voice: normalizeAudioVoiceValue(config.audioVoice),
response_format: format,
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
...(instructions ? { instructions } : {}),
},
{ headers: request.headers, withCredentials: request.credentials === "include", responseType: "blob", signal: options?.signal },
);
const response = await axios.post<Blob>(request.url, payload, { headers: request.headers, withCredentials: request.credentials === "include", responseType: "blob", signal: options?.signal });
await assertAudioBlob(response.data);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
} catch (error) {
@@ -48,6 +48,104 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
}
}
async function requestAsyncAudioGeneration(config: AiConfig, payload: Record<string, unknown>, format: string, options?: RequestOptions) {
const createRequest = channelRequest(config, aiApiUrl(config, "/audio/tasks"), aiHeaders(config));
const created = await axios.post<Record<string, unknown>>(createRequest.url, payload, { headers: createRequest.headers, withCredentials: createRequest.credentials === "include", signal: options?.signal });
let state = asyncAudioPayload(created.data);
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 pollRequest = channelRequest(config, aiApiUrl(config, `/audio/tasks/${encodeURIComponent(taskId)}`), aiHeaders(config));
const polled = await axios.get<Record<string, unknown>>(pollRequest.url, { headers: pollRequest.headers, withCredentials: pollRequest.credentials === "include", signal: options?.signal });
state = asyncAudioPayload(polled.data);
}
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 axios.get<Blob>(resultUrl, { responseType: "blob", signal: options?.signal })).data;
} else {
const contentRequest = channelRequest(config, aiApiUrl(config, `/audio/tasks/${encodeURIComponent(taskId)}/content`), aiHeaders(config));
blob = (await axios.get<Blob>(contentRequest.url, { headers: contentRequest.headers, withCredentials: contentRequest.credentials === "include", responseType: "blob", signal: options?.signal })).data;
}
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");
@@ -61,7 +159,9 @@ function assertAudioConfig(config: AiConfig, model: string) {
}
async function assertAudioBlob(blob: Blob) {
if (!blob.type.includes("json")) return;
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 } };