198 lines
6.2 KiB
TypeScript
198 lines
6.2 KiB
TypeScript
import { z } from "zod";
|
||
const qwenVoices = [
|
||
{ id: "Cherry", name: "芊悦" },
|
||
{ id: "Serena", name: "苏瑶" },
|
||
];
|
||
export const audioModelContracts = {
|
||
"qwen3-tts-instruct-flash": {
|
||
kind: "tts",
|
||
label: "千问3 TTS 指令版",
|
||
voices: qwenVoices,
|
||
instructions: true,
|
||
controls: false,
|
||
formats: ["wav"],
|
||
},
|
||
"qwen3-tts-flash": {
|
||
kind: "tts",
|
||
label: "千问3 TTS",
|
||
voices: [...qwenVoices, { id: "Ryan", name: "甜茶" }, { id: "Jennifer", name: "詹妮弗" }],
|
||
instructions: false,
|
||
controls: false,
|
||
formats: ["wav"],
|
||
},
|
||
"qwen-audio-3.0-tts-flash": {
|
||
kind: "tts",
|
||
label: "千问 Audio 3.0 Flash",
|
||
voices: [
|
||
{ id: "longanhuan_v3.6", name: "龙安欢" },
|
||
{ id: "longanfengyue", name: "龙安风悦" },
|
||
{ id: "longanyuanfei", name: "龙安元妃" },
|
||
{ id: "longanlingxi", name: "龙安灵希" },
|
||
],
|
||
instructions: true,
|
||
controls: true,
|
||
formats: ["mp3", "wav"],
|
||
},
|
||
"qwen-audio-3.0-tts-plus": {
|
||
kind: "tts",
|
||
label: "千问 Audio 3.0 Plus",
|
||
voices: [
|
||
{ id: "longanlingxin", name: "龙安灵心" },
|
||
{ id: "longanlufeng", name: "龙安鲁风" },
|
||
],
|
||
instructions: true,
|
||
controls: true,
|
||
formats: ["mp3", "wav"],
|
||
},
|
||
"fun-music-v1": {
|
||
kind: "music",
|
||
label: "Fun-Music 音乐",
|
||
voices: [],
|
||
instructions: false,
|
||
controls: false,
|
||
formats: ["mp3", "wav"],
|
||
},
|
||
} satisfies Record<
|
||
string,
|
||
{
|
||
kind: string;
|
||
label: string;
|
||
voices: { id: string; name: string }[];
|
||
instructions: boolean;
|
||
controls: boolean;
|
||
formats: string[];
|
||
}
|
||
>;
|
||
export type AudioModelId = keyof typeof audioModelContracts;
|
||
export const audioRequestSchema = z.object({
|
||
mode: z.enum(["tts", "music"]),
|
||
modelRef: z.string().min(1),
|
||
text: z.string().trim().max(2000).default(""),
|
||
lyrics: z.string().trim().max(2000).default(""),
|
||
voice: z.string().max(100).optional(),
|
||
language: z
|
||
.enum([
|
||
"Auto",
|
||
"Chinese",
|
||
"English",
|
||
"German",
|
||
"Italian",
|
||
"Portuguese",
|
||
"Spanish",
|
||
"Japanese",
|
||
"Korean",
|
||
"French",
|
||
"Russian",
|
||
])
|
||
.default("Chinese"),
|
||
instructions: z.string().trim().max(1200).default(""),
|
||
format: z.enum(["mp3", "wav"]).default("wav"),
|
||
instrumental: z.boolean().default(true),
|
||
gender: z.enum(["male", "female"]).default("female"),
|
||
rate: z.number().min(0.5).max(2).default(1),
|
||
pitch: z.number().min(0.5).max(2).default(1),
|
||
volume: z.number().int().min(0).max(100).default(50),
|
||
});
|
||
export type AudioRequest = z.infer<typeof audioRequestSchema>;
|
||
export function audioContract(id: string) {
|
||
return Object.hasOwn(audioModelContracts, id)
|
||
? audioModelContracts[id as AudioModelId]
|
||
: undefined;
|
||
}
|
||
export function officialAudioBase(base: string, modelId?: string) {
|
||
const url = new URL(base);
|
||
if (
|
||
url.protocol !== "https:" ||
|
||
url.username ||
|
||
url.password ||
|
||
!/^(?:dashscope(?:-intl|-us)?\.aliyuncs\.com|[a-zA-Z0-9-]+\.(?:cn-beijing|ap-southeast-1)\.maas\.aliyuncs\.com)$/.test(
|
||
url.hostname,
|
||
)
|
||
)
|
||
throw Error("请选择阿里云官方音频服务线路");
|
||
if (
|
||
(modelId === "fun-music-v1" || modelId?.startsWith("qwen-audio-")) &&
|
||
url.hostname !== "dashscope.aliyuncs.com" &&
|
||
!url.hostname.endsWith(".cn-beijing.maas.aliyuncs.com")
|
||
)
|
||
throw Error("所选模型仅支持阿里云北京地域");
|
||
return url.origin;
|
||
}
|
||
export function buildAudioRequest(value: unknown, modelId: string) {
|
||
const p = audioRequestSchema.parse(value),
|
||
contract = audioContract(modelId);
|
||
if (!contract || contract.kind !== p.mode) throw Error("音频模式与模型不匹配");
|
||
if (!contract.formats.includes(p.format)) throw Error("所选模型不支持这个音频格式");
|
||
if (p.mode === "music") {
|
||
if ((p.instrumental && !p.text) || (!p.text && !p.lyrics)) throw Error("请输入音乐描述或歌词");
|
||
if (
|
||
!p.instrumental &&
|
||
p.lyrics &&
|
||
(p.lyrics.length < 5 || (/[\u3400-\u9fff]/.test(p.lyrics) && p.lyrics.length > 350))
|
||
)
|
||
throw Error("中文歌词需 5–350 字,英文歌词需 5–2000 字");
|
||
return {
|
||
path: "/api/v1/services/audio/music/generation",
|
||
body: {
|
||
model: modelId,
|
||
input: {
|
||
...(p.text ? { prompt: p.text } : {}),
|
||
...(!p.instrumental && p.lyrics ? { lyrics: p.lyrics } : {}),
|
||
is_instrumental: p.instrumental,
|
||
...(!p.instrumental ? { gender: p.gender } : {}),
|
||
format: p.format,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
if (!p.text || p.text.length > 600) throw Error("配音文本需 1–600 字");
|
||
if (!contract.voices.some((v) => v.id === p.voice)) throw Error("请选择当前模型支持的音色");
|
||
if (!contract.instructions && p.instructions) throw Error("所选模型不支持风格指令");
|
||
if (!contract.controls && (p.rate !== 1 || p.pitch !== 1 || p.volume !== 50))
|
||
throw Error("所选模型不支持数值语速、音调和音量参数,可改用风格指令");
|
||
const modern = modelId.startsWith("qwen-audio-");
|
||
if (modern && !["Chinese", "English", "Auto"].includes(p.language))
|
||
throw Error("当前系统音色支持中文和英文");
|
||
return {
|
||
path: modern
|
||
? "/api/v1/services/audio/tts/SpeechSynthesizer"
|
||
: "/api/v1/services/aigc/multimodal-generation/generation",
|
||
body: {
|
||
model: modelId,
|
||
input: {
|
||
text: p.text,
|
||
voice: p.voice,
|
||
...(modern
|
||
? {
|
||
format: p.format,
|
||
rate: p.rate,
|
||
pitch: p.pitch,
|
||
volume: p.volume,
|
||
...(p.instructions ? { instruction: p.instructions } : {}),
|
||
...(p.language !== "Auto"
|
||
? { language_hints: [p.language === "Chinese" ? "zh" : "en"] }
|
||
: {}),
|
||
}
|
||
: {
|
||
language_type: p.language,
|
||
...(p.instructions
|
||
? { instructions: p.instructions, optimize_instructions: true }
|
||
: {}),
|
||
}),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
export const audioCheckpointSchema = z.object({
|
||
operation: z.literal("audio"),
|
||
modelRef: z.string(),
|
||
modelId: z.string(),
|
||
url: z
|
||
.string()
|
||
.url()
|
||
.refine((v) => /^https?:\/\//i.test(v)),
|
||
format: z.enum(["mp3", "wav"]),
|
||
requestId: z.string().optional(),
|
||
expiresAt: z.number().optional(),
|
||
});
|