dsh-audiogen 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-audiogen",
3
3
  "description": "AI audio generation plugin for the dsh web GUI: multi-vendor TTS/music/sound-effect channels (OpenAI-compatible, ElevenLabs, MiniMax, Stability AI and custom), per-channel model/voice catalogs, Agent tool and a sidebar AI 音频 panel.",
4
- "version": "0.3.0",
4
+ "version": "0.3.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -2,15 +2,38 @@
2
2
 
3
3
  ## 触发
4
4
  - `/audio:music <描述>`
5
- - 用户说“生成一段音乐 / 配乐 / BGM
5
+ - 用户说“生成一段音乐 / 配乐 / BGM / 纯音乐”
6
6
 
7
7
  ## 参数
8
8
  - prompt: 必填,风格/情绪/乐器/时长描述
9
- - model: 可选,已配置的音频模型
10
- - duration: 可选,秒数
11
- - format: 可选,mp3 / wav
9
+ - model: 可选,已配置的音频模型(MiniMax:music-3.0 / music-2.6 / music-cover)
10
+ - lyrics: 可选,歌词;**MiniMax music-3.0 / music-cover 必填**(除非 is_instrumental=true);多段用空行分隔
11
+ - is_instrumental: 可选,是否纯音乐(无歌词/人声),true lyrics 可留空
12
+ - duration: 可选,秒数(MiniMax 一般 5-120s)
13
+ - format: 可选,MiniMax 音乐仅 mp3 / wav / pcm
12
14
 
13
15
  ## 流程
14
- 1. 确认已配置支持音乐生成的渠道(如 Stability Audio / 自定义)。
15
- 2. 调用 `generate_audio`,mode=music。
16
- 3. 将生成的音频 URL 返回给用户。
16
+ 1. 确认已配置支持音乐生成的渠道(如 MiniMax / Stability Audio / 自定义)。
17
+ 2. 若用户未给歌词且未要求纯音乐:对 MiniMax 渠道先创作/补全一段歌词再调用。
18
+ 3. 调用 `generate_audio`,mode=music。
19
+ 4. 将生成的音频 URL 返回给用户。
20
+
21
+ ## MiniMax 官方 music_generation 字段参考(POST /v1/music_generation)
22
+
23
+ | 字段 | 工具参数 | 说明 |
24
+ | --- | --- | --- |
25
+ | model | model | music-3.0 / music-2.6 / music-cover |
26
+ | prompt | prompt | 音乐风格/情绪/乐器描述(≤3000 字) |
27
+ | lyrics | lyrics | 歌词;多段用空行分隔;纯音乐模式可留空 |
28
+ | is_instrumental | is_instrumental | 默认 false;true = 纯音乐(无歌词/人声),此时 lyrics 可省 |
29
+ | duration | duration | 生成时长(秒) |
30
+ | audio_setting.format | format | **mp3 / wav / pcm**(仅此三种) |
31
+ | audio_setting.sample_rate | sample_rate | **16000 / 24000 / 32000 / 44100**(无 48000) |
32
+ | audio_setting.bitrate | bitrate | **32000 / 64000 / 128000 / 256000** |
33
+
34
+ > 引擎对音乐 audio_setting 按上述枚举校验,超出枚举的取值自动回退默认(format=mp3、sample_rate=44100、bitrate=256000)。
35
+ > 若不加 lyrics 也未开启纯音乐,引擎会直接提示 `lyrics-required`(MiniMax 上游返回 2013 lyrics is required)。
36
+
37
+ ## 常见错误
38
+ - `lyrics-required`:MiniMax 音乐生成需要歌词,或开启纯音乐。
39
+ - `HTTP 400` 且含 `2013`:上游参数不合法,检查 lyrics / audio_setting 枚举。
@@ -6,13 +6,51 @@
6
6
 
7
7
  ## 参数
8
8
  - text: 必填,要朗读的文本
9
- - model: 可选,已配置的模型/音色
10
- - voice: 可选,音色
11
- - speed: 可选,语速倍率
12
- - format: 可选,mp3 / wav
9
+ - model: 可选,已配置的模型/音色(MiniMax 为 speech-2.6/2.8 系列)
10
+ - voice: 可选,音色;**MiniMax 必填**(voice_id,如 male-qn-qingse、female-shaonv)
11
+ - speed: 可选,语速倍率(MiniMax 0.5-2.0,默认 1)
12
+ - format: 可选,mp3 / wav / flac / aac / pcm
13
13
 
14
14
  ## 流程
15
15
  1. 确认已配置音频渠道(设置 → 插件 → AI 音频)。
16
16
  2. 若用户未指定模型且有多个,先询问。
17
17
  3. 调用 `generate_audio` 工具,mode=tts。
18
18
  4. 把返回的音频 URL 提供给用户,可播放/下载。
19
+
20
+ ## MiniMax 官方 t2a_v2 字段参考(POST /v1/t2a_v2)
21
+
22
+ 引擎按官方协议逐字段透传(无值时不发送);以下字段均可在 `generate_audio` 中按需传入(仅 MiniMax 渠道生效):
23
+
24
+ | 字段 | 工具参数 | 说明 |
25
+ | --- | --- | --- |
26
+ | model | model | 模型:speech-2.8-hd / speech-2.8-turbo / speech-2.6-hd / speech-2.6-turbo / speech-02-hd / speech-02-turbo |
27
+ | text | prompt | 文本,支持 (laughs) 等标签 |
28
+ | stream | — | 固定 false(引擎非流式消费) |
29
+ | voice_setting.voice_id | voice | **必填**音色;账号音色可在设置中「获取可用模型」拉取 |
30
+ | voice_setting.speed | speed | 0.5-2.0,默认 1 |
31
+ | voice_setting.vol | vol | 音量 0-10,默认 1 |
32
+ | voice_setting.pitch | pitch | 音调偏移 -12~12,默认 0 |
33
+ | voice_setting.emotion | emotion | 情绪:happy / sad / angry / nervous / fearful / bored 等 |
34
+ | voice_setting.text_normalization | text_normalization | 文本归一化开关 |
35
+ | voice_setting.latex_read | latex_read | 数学公式朗读开关 |
36
+ | pronunciation_dict.tone | pronunciation_tone | 发音词典条目数组,如 ["处理/(chu3)(li3)", "危险/dangerous"](每项 "文字/读音") |
37
+ | audio_setting.format | format | mp3 / wav / pcm,默认 mp3 |
38
+ | audio_setting.sample_rate | sample_rate | 16000/24000/32000/44100/48000,默认 32000 |
39
+ | audio_setting.bitrate | bitrate | 64000-320000,默认 128000 |
40
+ | audio_setting.channel | channel | 1 或 2,默认 1 |
41
+ | audio_setting.force_cbr | force_cbr | 强制 CBR 编码 |
42
+ | subtitle_enable | subtitle_enable | 生成字幕(响应携带字幕内容) |
43
+ | aigc_watermark | aigc_watermark | AIGC 水印 |
44
+ | language_boost | language_boost | 语言增强(模型相关,如中英混读) |
45
+ | voice_modify | voice_modify | 变声 {pitch, intensity, timbre, sound_effects}(speech-2.8 等支持) |
46
+ | timbre_weights | timbre_weights | 双音色混合 [{voice_id, weight}] |
47
+
48
+ ### 网关/代理渠道
49
+ - 官方默认地址 `https://api.minimaxi.com`(原生 `/v1/t2a_v2`,字段全量支持)。
50
+ - 若渠道配置为 New API 一类网关(只暴露 OpenAI 兼容 `/v1/audio/speech`,对 `/v1/t2a_v2` 返回 404 Invalid URL),引擎会自动回退到 `/v1/audio/speech`,并把上述官方字段放进 `metadata` 供网关合并转发;此类网关的字段支持取决于其实现。
51
+ - 回退也失败时,错误信息会同时给出两种端点与排查建议。
52
+
53
+ ### 常见错误
54
+ - `voice-required`:未选择音色,需传 voice(voice_id)。
55
+ - HTTP 404 Invalid URL:网关未路由 `/v1/t2a_v2`(已自动回退)。
56
+ - `HTTP 400` 且 base_resp.status_code 非 0:上游参数不合法(如 emotion 不受该音色支持)。
@@ -105,11 +105,51 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
105
105
  prompt: { type: 'string', required: true, description: 'For tts, the text to speak. For music/sfx, a descriptive prompt.' },
106
106
  mode: { type: 'string', enum: ['tts', 'music', 'sfx', 'voice_design'], description: 'Generation mode. Defaults to tts.' },
107
107
  model: { type: 'string', description: 'One of the configured audio models/voices. Defaults to the first configured model.' },
108
- voice: { type: 'string', description: 'Optional voice id/name for TTS providers.' },
108
+ voice: { type: 'string', description: 'Optional voice id/name for TTS providers. Required for MiniMax TTS (e.g. male-qn-qingse, female-shaonv); fetch the account voices in Settings > Plugins > AI Audio.' },
109
109
  preview_text: { type: 'string', description: 'Optional preview text for voice_design.' },
110
- speed: { type: 'number', description: 'Optional speaking rate / speed multiplier where supported.' },
110
+ speed: { type: 'number', description: 'Optional speaking rate / speed multiplier where supported. MiniMax range 0.5-2.0 (default 1).' },
111
111
  duration: { type: 'number', description: 'Requested duration in seconds for music/sfx.' },
112
- format: { type: 'string', description: 'Output format such as mp3 or wav.' },
112
+ lyrics: { type: 'string', description: 'Lyrics for music generation (MiniMax music-3.0/music-cover). Required unless is_instrumental is true. Split verses with an empty line.' },
113
+ is_instrumental: { type: 'boolean', description: 'Generate purely instrumental music without vocals/lyrics (MiniMax is_instrumental). When true, lyrics may be omitted.' },
114
+ format: { type: 'string', description: 'Output format such as mp3 or wav. MiniMax music supports mp3/wav/pcm.' },
115
+ // ---- MiniMax TTS only (ignored by other providers) ----
116
+ emotion: { type: 'string', description: 'MiniMax TTS emotion, e.g. happy/sad/angry/nervous/fearful/bored (voice_setting.emotion).' },
117
+ vol: { type: 'number', description: 'MiniMax TTS volume 0-10, default 1 (voice_setting.vol).' },
118
+ pitch: { type: 'integer', description: 'MiniMax TTS pitch shift -12..12 semitones, default 0 (voice_setting.pitch).' },
119
+ text_normalization: { type: 'boolean', description: 'MiniMax TTS text normalization switch (voice_setting.text_normalization).' },
120
+ latex_read: { type: 'boolean', description: 'MiniMax TTS math formula reading switch (voice_setting.latex_read).' },
121
+ pronunciation_tone: { type: 'array', items: { type: 'string' }, description: 'MiniMax TTS pronunciation dictionary tone entries, each "word/pronunciation", e.g. ["处理/(chu3)(li3)", "危险/dangerous"] (pronunciation_dict.tone).' },
122
+ sample_rate: { type: 'integer', description: 'MiniMax TTS sample rate: 16000/24000/32000/44100/48000, default 32000 (audio_setting.sample_rate).' },
123
+ bitrate: { type: 'integer', description: 'MiniMax TTS bitrate in bps: 64000-320000, default 128000 (audio_setting.bitrate).' },
124
+ channel: { type: 'integer', description: 'MiniMax TTS audio channels: 1 or 2, default 1 (audio_setting.channel).' },
125
+ force_cbr: { type: 'boolean', description: 'MiniMax TTS force CBR encoding (audio_setting.force_cbr).' },
126
+ subtitle_enable: { type: 'boolean', description: 'MiniMax TTS subtitle output switch (subtitle_enable).' },
127
+ aigc_watermark: { type: 'boolean', description: 'MiniMax TTS AIGC watermark switch (aigc_watermark).' },
128
+ language_boost: { type: 'string', description: 'MiniMax TTS language boost, e.g. 中英混读 (language_boost, model-dependent).' },
129
+ voice_modify: {
130
+ type: 'object',
131
+ additionalProperties: false,
132
+ properties: {
133
+ pitch: { type: 'integer', description: 'Pitch shift for voice modification.' },
134
+ intensity: { type: 'integer', description: 'Intensity for voice modification.' },
135
+ timbre: { type: 'integer', description: 'Timbre shift for voice modification.' },
136
+ sound_effects: { type: 'string', description: 'Sound effect for voice modification, e.g. 耳语.' },
137
+ },
138
+ description: 'MiniMax TTS voice modification (voice_modify, supported by speech-2.8+).',
139
+ },
140
+ timbre_weights: {
141
+ type: 'array',
142
+ items: {
143
+ type: 'object',
144
+ additionalProperties: false,
145
+ properties: {
146
+ voice_id: { type: 'string' },
147
+ weight: { type: 'integer' },
148
+ },
149
+ required: ['voice_id', 'weight'],
150
+ },
151
+ description: 'MiniMax TTS dual-voice blend weights (timbre_weights).',
152
+ },
113
153
  },
114
154
  output: {
115
155
  schema: resultSchema,
@@ -129,6 +169,23 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
129
169
  return { channel: target, alias: '', upstream: '' }
130
170
  })()
131
171
  : resolveModel(config, args.model)
172
+ const voiceModify = typeof args.voice_modify === 'object' && args.voice_modify !== null
173
+ ? (() => {
174
+ const raw = args.voice_modify as Record<string, unknown>
175
+ const out: { pitch?: number; intensity?: number; timbre?: number; soundEffects?: string } = {}
176
+ if (typeof raw.pitch === 'number') out.pitch = raw.pitch
177
+ if (typeof raw.intensity === 'number') out.intensity = raw.intensity
178
+ if (typeof raw.timbre === 'number') out.timbre = raw.timbre
179
+ if (typeof raw.sound_effects === 'string' && raw.sound_effects.trim() !== '') out.soundEffects = raw.sound_effects.trim()
180
+ return Object.keys(out).length > 0 ? out : undefined
181
+ })()
182
+ : undefined
183
+ const timbreWeights = Array.isArray(args.timbre_weights)
184
+ ? args.timbre_weights
185
+ .filter((item): item is { voice_id: string; weight: number } => typeof item === 'object' && item !== null && typeof (item as { voice_id?: unknown }).voice_id === 'string' && typeof (item as { weight?: unknown }).weight === 'number')
186
+ .map(item => ({ voiceId: (item.voice_id as string).trim(), weight: item.weight as number }))
187
+ .filter(item => item.voiceId !== '')
188
+ : undefined
132
189
  const request: GenerateAudioRequest = {
133
190
  mode,
134
191
  model: picked.alias,
@@ -140,7 +197,27 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
140
197
  ...(typeof args.preview_text === 'string' && args.preview_text.trim() !== '' ? { previewText: args.preview_text.trim() } : {}),
141
198
  ...(typeof args.speed === 'number' ? { speed: args.speed } : {}),
142
199
  ...(typeof args.duration === 'number' ? { duration: args.duration } : {}),
200
+ ...(typeof args.lyrics === 'string' && args.lyrics.trim() !== '' ? { lyrics: args.lyrics.trim() } : {}),
201
+ ...(typeof args.is_instrumental === 'boolean' ? { isInstrumental: args.is_instrumental } : {}),
143
202
  ...(typeof args.format === 'string' && args.format.trim() !== '' ? { format: args.format.trim() } : {}),
203
+ // ---- MiniMax TTS 专属字段 ----
204
+ ...(typeof args.emotion === 'string' && args.emotion.trim() !== '' ? { emotion: args.emotion.trim() } : {}),
205
+ ...(typeof args.vol === 'number' && Number.isFinite(args.vol) ? { vol: args.vol } : {}),
206
+ ...(typeof args.pitch === 'number' && Number.isFinite(args.pitch) ? { pitch: args.pitch } : {}),
207
+ ...(typeof args.text_normalization === 'boolean' ? { textNormalization: args.text_normalization } : {}),
208
+ ...(typeof args.latex_read === 'boolean' ? { latexRead: args.latex_read } : {}),
209
+ ...(Array.isArray(args.pronunciation_tone) && args.pronunciation_tone.length > 0
210
+ ? { pronunciationTone: args.pronunciation_tone.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map((item: string) => item.trim()) }
211
+ : {}),
212
+ ...(typeof args.sample_rate === 'number' && Number.isFinite(args.sample_rate) ? { sampleRate: args.sample_rate } : {}),
213
+ ...(typeof args.bitrate === 'number' && Number.isFinite(args.bitrate) ? { bitrate: args.bitrate } : {}),
214
+ ...(typeof args.channel === 'number' && Number.isFinite(args.channel) ? { audioChannel: args.channel } : {}),
215
+ ...(typeof args.force_cbr === 'boolean' ? { forceCbr: args.force_cbr } : {}),
216
+ ...(typeof args.subtitle_enable === 'boolean' ? { subtitleEnable: args.subtitle_enable } : {}),
217
+ ...(typeof args.aigc_watermark === 'boolean' ? { aigcWatermark: args.aigc_watermark } : {}),
218
+ ...(typeof args.language_boost === 'string' && args.language_boost.trim() !== '' ? { languageBoost: args.language_boost.trim() } : {}),
219
+ ...(voiceModify !== undefined ? { voiceModify } : {}),
220
+ ...(timbreWeights !== undefined && timbreWeights.length > 0 ? { timbreWeights } : {}),
144
221
  }
145
222
  try {
146
223
  const outputs = await generateAudio(picked.channel, request, exec.signal)
@@ -124,7 +124,7 @@ function findBase64Audio(value: unknown): string | undefined {
124
124
  }
125
125
  if (value === null || typeof value !== 'object') return undefined
126
126
  const record = value as Record<string, unknown>
127
- for (const key of ['audio', 'b64_json', 'base64', 'data', 'output', 'result', 'value']) {
127
+ for (const key of ['audio', 'music', 'b64_json', 'base64', 'data', 'output', 'result', 'value']) {
128
128
  const candidate = record[key]
129
129
  const found = findBase64Audio(candidate)
130
130
  if (found !== undefined) return found
@@ -274,10 +274,127 @@ function minimaxApiBase(base: string): string {
274
274
  return /\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/v1`
275
275
  }
276
276
 
277
+ /**
278
+ * Resolve the MiniMax voice_id for a TTS request.
279
+ * Priority: explicit voice param → upstream id (if it is not a model name) →
280
+ * model alias (if it is not a model name). MiniMax speech/music model ids
281
+ * (speech-2.8-hd, music-3.0, …) are never treated as voice ids.
282
+ */
283
+ function resolveMiniMaxVoice(request: GenerateAudioRequest): string | undefined {
284
+ const explicit = request.voice?.trim()
285
+ if (explicit !== undefined && explicit !== '') return explicit
286
+ for (const candidate of [request.upstream, request.model]) {
287
+ const value = typeof candidate === 'string' ? candidate.trim() : ''
288
+ if (value === '') continue
289
+ if (/^(speech|music|t2a|tts)[-_]/i.test(value)) continue
290
+ return value
291
+ }
292
+ return undefined
293
+ }
294
+
295
+ /**
296
+ * Build the full MiniMax t2a_v2 body. Every official field is carried
297
+ * through — voice_setting (voice_id/speed/vol/pitch/emotion/text_normalization/
298
+ * latex_read), pronunciation_dict.tone, audio_setting (format/sample_rate/
299
+ * bitrate/channel/force_cbr), subtitle_enable, aigc_watermark, language_boost,
300
+ * voice_modify and timbre_weights — so callers and skills can reference them.
301
+ */
302
+ function buildMiniMaxTTSBody(request: GenerateAudioRequest, model: string, voiceId: string): Record<string, unknown> {
303
+ const body: Record<string, unknown> = {
304
+ model,
305
+ text: request.prompt,
306
+ stream: false,
307
+ voice_setting: {
308
+ voice_id: voiceId,
309
+ speed: request.speed ?? 1,
310
+ vol: request.vol ?? 1,
311
+ pitch: request.pitch ?? 0,
312
+ ...(request.emotion !== undefined && request.emotion.trim() !== '' ? { emotion: request.emotion.trim() } : {}),
313
+ ...(request.textNormalization !== undefined ? { text_normalization: request.textNormalization } : {}),
314
+ ...(request.latexRead !== undefined ? { latex_read: request.latexRead } : {}),
315
+ },
316
+ audio_setting: {
317
+ format: request.format ?? 'mp3',
318
+ sample_rate: request.sampleRate ?? 32000,
319
+ bitrate: request.bitrate ?? 128000,
320
+ channel: request.audioChannel ?? 1,
321
+ ...(request.forceCbr !== undefined ? { force_cbr: request.forceCbr } : {}),
322
+ },
323
+ }
324
+ if (request.pronunciationTone !== undefined && request.pronunciationTone.length > 0) {
325
+ body.pronunciation_dict = { tone: request.pronunciationTone }
326
+ }
327
+ if (request.subtitleEnable !== undefined) body.subtitle_enable = request.subtitleEnable
328
+ if (request.aigcWatermark !== undefined) body.aigc_watermark = request.aigcWatermark
329
+ if (request.languageBoost !== undefined && request.languageBoost.trim() !== '') {
330
+ body.language_boost = request.languageBoost.trim()
331
+ }
332
+ if (request.voiceModify !== undefined) {
333
+ const modify: Record<string, unknown> = {}
334
+ if (request.voiceModify.pitch !== undefined) modify.pitch = request.voiceModify.pitch
335
+ if (request.voiceModify.intensity !== undefined) modify.intensity = request.voiceModify.intensity
336
+ if (request.voiceModify.timbre !== undefined) modify.timbre = request.voiceModify.timbre
337
+ if (request.voiceModify.soundEffects !== undefined && request.voiceModify.soundEffects.trim() !== '') {
338
+ modify.sound_effects = request.voiceModify.soundEffects.trim()
339
+ }
340
+ if (Object.keys(modify).length > 0) body.voice_modify = modify
341
+ }
342
+ if (request.timbreWeights !== undefined && request.timbreWeights.length > 0) {
343
+ body.timbre_weights = request.timbreWeights
344
+ .filter(item => typeof item?.voiceId === 'string' && item.voiceId.trim() !== '' && typeof item.weight === 'number')
345
+ .map(item => ({ voice_id: item.voiceId.trim(), weight: item.weight }))
346
+ }
347
+ return body
348
+ }
349
+
350
+ /** The MiniMax-specific fields only (model/text/stream excluded) — used as the
351
+ * new-api `metadata` payload when a gateway serves MiniMax TTS at /v1/audio/speech.
352
+ * The merge keeps the gateway-sent model/input, and voice_setting.voice_id is
353
+ * carried explicitly so relays that overwrite it still get the right voice. */
354
+ function buildMiniMaxTTSUpload(request: GenerateAudioRequest, voiceId: string): Record<string, unknown> {
355
+ const upload = buildMiniMaxTTSBody(request, '', voiceId)
356
+ delete upload.model
357
+ delete upload.text
358
+ delete upload.stream
359
+ return upload
360
+ }
361
+
362
+ /**
363
+ * OpenAI-compatible MiniMax TTS path for New API style gateways that do not
364
+ * route the native /v1/t2a_v2. The full native field set is carried inside
365
+ * `metadata`, which new-api's MiniMax TTS relay merges into t2a_v2 upstream.
366
+ */
367
+ async function minimaxTTSGateway(channel: AudioChannel, request: GenerateAudioRequest, signal: AbortSignal | undefined, voiceId: string): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
368
+ const base = minimaxApiBase(channel.apiUrl)
369
+ const endpoint = `${base}/audio/speech`
370
+ const model = (request.upstream ?? request.model) || 'speech-2.8-hd'
371
+ const metadata = buildMiniMaxTTSUpload(request, voiceId)
372
+ const body: Record<string, unknown> = {
373
+ model,
374
+ input: request.prompt,
375
+ voice: voiceId,
376
+ response_format: request.format ?? 'mp3',
377
+ ...(request.speed !== undefined ? { speed: request.speed } : {}),
378
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
379
+ }
380
+ const response = await fetchWithTimeout(endpoint, {
381
+ method: 'POST',
382
+ // Gateways may answer with a redirect to the real audio URL — follow it.
383
+ redirect: 'follow',
384
+ headers: {
385
+ authorization: `Bearer ${channel.apiKey.trim()}`,
386
+ 'content-type': 'application/json',
387
+ accept: 'application/json, audio/mpeg',
388
+ },
389
+ body: JSON.stringify(body),
390
+ signal,
391
+ }, UPSTREAM_TIMEOUT_MS)
392
+ return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
393
+ }
394
+
277
395
  async function minimax(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
278
396
  const base = minimaxApiBase(channel.apiUrl)
279
397
  const model = (request.upstream ?? request.model) || (request.mode === 'music' ? 'music-3.0' : 'speech-2.8-hd')
280
- const voice = request.voice ?? request.model ?? ''
281
398
 
282
399
  if (request.mode === 'voice_design') {
283
400
  const endpoint = `${base}/voice_design`
@@ -319,40 +436,62 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
319
436
  }]
320
437
  }
321
438
 
322
- let endpoint: string
323
- let body: Record<string, unknown>
324
439
  if (request.mode === 'music') {
325
- endpoint = `${base}/music_generation`
326
- body = {
440
+ // MiniMax 音乐生成官方字段:model/lyrics/prompt/is_instrumental/duration/
441
+ // audio_setting{format, sample_rate, bitrate}。音频输出配置为固定枚举:
442
+ // format mp3|wav|pcm;sample_rate 16000|24000|32000|44100;
443
+ // bitrate 32000|64000|128000|256000,超出枚举的值回退默认。
444
+ const MUSIC_FORMATS = new Set(['mp3', 'wav', 'pcm'])
445
+ const MUSIC_SAMPLE_RATES = new Set([16000, 24000, 32000, 44100])
446
+ const MUSIC_BITRATES = new Set([32000, 64000, 128000, 256000])
447
+ const lyrics = request.lyrics?.trim() ?? ''
448
+ if (lyrics === '' && request.isInstrumental !== true) {
449
+ throw new AudioGenError(
450
+ 'MiniMax 音乐生成需要歌词(lyrics 参数),或在「纯音乐」模式(is_instrumental=true)下生成;也可让面板/Agent 先为提示词创作一段歌词。',
451
+ 'lyrics-required',
452
+ )
453
+ }
454
+ const endpoint = `${base}/music_generation`
455
+ const body: Record<string, unknown> = {
327
456
  model,
328
457
  prompt: request.prompt,
458
+ ...(lyrics === '' ? {} : { lyrics }),
459
+ ...(request.isInstrumental !== undefined ? { is_instrumental: request.isInstrumental } : {}),
329
460
  ...(request.duration !== undefined ? { duration: request.duration } : {}),
330
461
  audio_setting: {
331
- format: request.format ?? 'mp3',
332
- sample_rate: 44100,
333
- bitrate: 256000,
462
+ format: MUSIC_FORMATS.has(request.format ?? 'mp3') ? (request.format ?? 'mp3') : 'mp3',
463
+ sample_rate: MUSIC_SAMPLE_RATES.has(request.sampleRate ?? 44100) ? (request.sampleRate ?? 44100) : 44100,
464
+ bitrate: MUSIC_BITRATES.has(request.bitrate ?? 256000) ? (request.bitrate ?? 256000) : 256000,
334
465
  },
335
466
  }
336
- } else {
337
- endpoint = `${base}/t2a_v2`
338
- body = {
339
- model,
340
- text: request.prompt,
341
- stream: false,
342
- ...(voice === '' ? {} : { voice_setting: {
343
- voice_id: voice,
344
- ...(request.speed !== undefined ? { speed: request.speed } : {}),
345
- vol: 1,
346
- pitch: 0,
347
- } }),
348
- audio_setting: {
349
- format: request.format ?? 'mp3',
350
- sample_rate: 32000,
351
- bitrate: 128000,
467
+ const response = await fetchWithTimeout(endpoint, {
468
+ method: 'POST',
469
+ redirect: 'error',
470
+ headers: {
471
+ authorization: `Bearer ${channel.apiKey.trim()}`,
472
+ 'content-type': 'application/json',
473
+ accept: 'application/json, audio/mpeg',
352
474
  },
475
+ body: JSON.stringify(body),
476
+ signal,
477
+ }, UPSTREAM_TIMEOUT_MS)
478
+ if (!response.ok) {
479
+ const detail = await response.text().catch(() => '')
480
+ throw new AudioGenError(`MiniMax music API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`, 'audio-api-error')
353
481
  }
482
+ return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
354
483
  }
355
484
 
485
+ // ------------------------------------------------------------- TTS
486
+ const voiceId = resolveMiniMaxVoice(request)
487
+ if (voiceId === undefined) {
488
+ throw new AudioGenError(
489
+ 'MiniMax TTS 需要指定音色 voice_id(如 male-qn-qingse、female-shaonv):请在「音色」字段填写,或把音色加入渠道模型目录(alias 可任意、upstream 填 voice_id),也可点「获取可用模型」拉取账号音色列表。',
490
+ 'voice-required',
491
+ )
492
+ }
493
+ const endpoint = `${base}/t2a_v2`
494
+ const body = buildMiniMaxTTSBody(request, model, voiceId)
356
495
  const response = await fetchWithTimeout(endpoint, {
357
496
  method: 'POST',
358
497
  redirect: 'error',
@@ -364,7 +503,27 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
364
503
  body: JSON.stringify(body),
365
504
  signal,
366
505
  }, UPSTREAM_TIMEOUT_MS)
367
- return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
506
+ if (response.ok) {
507
+ return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
508
+ }
509
+ const detail = await response.text().catch(() => '')
510
+ const routeMiss = response.status === 404 && /invalid url|invalid_request_error/i.test(detail)
511
+ if (!routeMiss) {
512
+ throw new AudioGenError(`MiniMax TTS API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`, 'audio-api-error')
513
+ }
514
+ // Gateway does not route the native MiniMax path — retry over its
515
+ // OpenAI-compatible /v1/audio/speech (new-api MiniMax relays merge
516
+ // `metadata` back into a full t2a_v2 request).
517
+ try {
518
+ return await minimaxTTSGateway(channel, request, signal, voiceId)
519
+ } catch (gatewayError) {
520
+ const detailText = gatewayError instanceof AudioGenError ? gatewayError.message : String(gatewayError)
521
+ throw new AudioGenError(
522
+ `MiniMax 渠道「${channel.name}」网关未提供原生 TTS 接口:POST ${endpoint} 返回 HTTP 404(Invalid URL,网关未路由 /v1/t2a_v2);已回退 OpenAI 兼容 ${minimaxApiBase(channel.apiUrl)}/audio/speech 仍失败:${detailText.slice(0, 300)}。`
523
+ + '请把渠道 API 地址配置为官方 https://api.minimaxi.com(配合 MiniMax 官方密钥),或确认网关已将 /v1/audio/speech 映射到 MiniMax 音色渠道。',
524
+ 'audio-api-error',
525
+ )
526
+ }
368
527
  }
369
528
 
370
529
  async function stabilityAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {