dsh-audiogen 0.2.0 → 0.3.2

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.2.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -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 不受该音色支持)。
@@ -24,6 +24,7 @@ interface AgentAudioRef {
24
24
  url: string
25
25
  mime: string
26
26
  bytes: number
27
+ voiceId?: string
27
28
  }
28
29
 
29
30
  interface AgentAudioResult {
@@ -43,6 +44,7 @@ const audioRefSchema = {
43
44
  url: { type: 'string', required: true },
44
45
  mime: { type: 'string', required: true },
45
46
  bytes: { type: 'integer', required: true },
47
+ voiceId: { type: 'string' },
46
48
  },
47
49
  } as const
48
50
 
@@ -52,7 +54,7 @@ const resultSchema = {
52
54
  properties: {
53
55
  status: { type: 'string', required: true },
54
56
  message: { type: 'string', required: true },
55
- mode: { type: 'string', required: true, enum: ['tts', 'music', 'sfx'] },
57
+ mode: { type: 'string', required: true, enum: ['tts', 'music', 'sfx', 'voice_design'] },
56
58
  model: { type: 'string', required: true },
57
59
  audio: { type: 'array', required: true, items: audioRefSchema },
58
60
  error: { type: 'string' },
@@ -98,15 +100,54 @@ function ensureConfigured(config: AgentAudioToolConfig): void {
98
100
  export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioToolConfig): () => void {
99
101
  const disposer = ctx.tools.register(defineTool({
100
102
  name: 'generate_audio',
101
- description: 'Generate audio with the configured audio provider. Supports text-to-speech, music generation and sound effects. The tool call waits for the upstream result and returns same-origin audio URLs; pass those URLs to the user for playback or download. If multiple models are configured, first ask the user which one to use or pass model explicitly.',
103
+ description: 'Generate audio with the configured audio provider. Supports text-to-speech, music generation, sound effects and MiniMax voice design. The tool call waits for the upstream result and returns same-origin audio URLs; pass those URLs to the user for playback or download. If multiple models are configured, first ask the user which one to use or pass model explicitly.',
102
104
  parameters: {
103
105
  prompt: { type: 'string', required: true, description: 'For tts, the text to speak. For music/sfx, a descriptive prompt.' },
104
- mode: { type: 'string', enum: ['tts', 'music', 'sfx'], description: 'Generation mode. Defaults to tts.' },
106
+ mode: { type: 'string', enum: ['tts', 'music', 'sfx', 'voice_design'], description: 'Generation mode. Defaults to tts.' },
105
107
  model: { type: 'string', description: 'One of the configured audio models/voices. Defaults to the first configured model.' },
106
- voice: { type: 'string', description: 'Optional voice id/name for TTS providers.' },
107
- speed: { type: 'number', description: 'Optional speaking rate / speed multiplier where supported.' },
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
+ preview_text: { type: 'string', description: 'Optional preview text for voice_design.' },
110
+ speed: { type: 'number', description: 'Optional speaking rate / speed multiplier where supported. MiniMax range 0.5-2.0 (default 1).' },
108
111
  duration: { type: 'number', description: 'Requested duration in seconds for music/sfx.' },
109
112
  format: { type: 'string', description: 'Output format such as mp3 or wav.' },
113
+ // ---- MiniMax TTS only (ignored by other providers) ----
114
+ emotion: { type: 'string', description: 'MiniMax TTS emotion, e.g. happy/sad/angry/nervous/fearful/bored (voice_setting.emotion).' },
115
+ vol: { type: 'number', description: 'MiniMax TTS volume 0-10, default 1 (voice_setting.vol).' },
116
+ pitch: { type: 'integer', description: 'MiniMax TTS pitch shift -12..12 semitones, default 0 (voice_setting.pitch).' },
117
+ text_normalization: { type: 'boolean', description: 'MiniMax TTS text normalization switch (voice_setting.text_normalization).' },
118
+ latex_read: { type: 'boolean', description: 'MiniMax TTS math formula reading switch (voice_setting.latex_read).' },
119
+ 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).' },
120
+ sample_rate: { type: 'integer', description: 'MiniMax TTS sample rate: 16000/24000/32000/44100/48000, default 32000 (audio_setting.sample_rate).' },
121
+ bitrate: { type: 'integer', description: 'MiniMax TTS bitrate in bps: 64000-320000, default 128000 (audio_setting.bitrate).' },
122
+ channel: { type: 'integer', description: 'MiniMax TTS audio channels: 1 or 2, default 1 (audio_setting.channel).' },
123
+ force_cbr: { type: 'boolean', description: 'MiniMax TTS force CBR encoding (audio_setting.force_cbr).' },
124
+ subtitle_enable: { type: 'boolean', description: 'MiniMax TTS subtitle output switch (subtitle_enable).' },
125
+ aigc_watermark: { type: 'boolean', description: 'MiniMax TTS AIGC watermark switch (aigc_watermark).' },
126
+ language_boost: { type: 'string', description: 'MiniMax TTS language boost, e.g. 中英混读 (language_boost, model-dependent).' },
127
+ voice_modify: {
128
+ type: 'object',
129
+ additionalProperties: false,
130
+ properties: {
131
+ pitch: { type: 'integer', description: 'Pitch shift for voice modification.' },
132
+ intensity: { type: 'integer', description: 'Intensity for voice modification.' },
133
+ timbre: { type: 'integer', description: 'Timbre shift for voice modification.' },
134
+ sound_effects: { type: 'string', description: 'Sound effect for voice modification, e.g. 耳语.' },
135
+ },
136
+ description: 'MiniMax TTS voice modification (voice_modify, supported by speech-2.8+).',
137
+ },
138
+ timbre_weights: {
139
+ type: 'array',
140
+ items: {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ properties: {
144
+ voice_id: { type: 'string' },
145
+ weight: { type: 'integer' },
146
+ },
147
+ required: ['voice_id', 'weight'],
148
+ },
149
+ description: 'MiniMax TTS dual-voice blend weights (timbre_weights).',
150
+ },
110
151
  },
111
152
  output: {
112
153
  schema: resultSchema,
@@ -117,18 +158,62 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
117
158
  async execute(args, exec) {
118
159
  const config = resolve()
119
160
  ensureConfigured(config)
120
- const picked = resolveModel(config, args.model)
161
+ const mode = args.mode === 'music' ? 'music' : args.mode === 'sfx' ? 'sfx' : args.mode === 'voice_design' ? 'voice_design' : 'tts'
162
+ const picked = mode === 'voice_design'
163
+ ? (() => {
164
+ const usable = config.channels.filter(channel => channel.apiUrl.trim() !== '' && channel.apiKey.trim() !== '')
165
+ const target = usable.find(channel => channel.id === config.defaultChannelId) ?? usable[0]
166
+ if (target === undefined) throw new AudioGenError('No usable audio channel is configured for voice design.', 'no-channel-available')
167
+ return { channel: target, alias: '', upstream: '' }
168
+ })()
169
+ : resolveModel(config, args.model)
170
+ const voiceModify = typeof args.voice_modify === 'object' && args.voice_modify !== null
171
+ ? (() => {
172
+ const raw = args.voice_modify as Record<string, unknown>
173
+ const out: { pitch?: number; intensity?: number; timbre?: number; soundEffects?: string } = {}
174
+ if (typeof raw.pitch === 'number') out.pitch = raw.pitch
175
+ if (typeof raw.intensity === 'number') out.intensity = raw.intensity
176
+ if (typeof raw.timbre === 'number') out.timbre = raw.timbre
177
+ if (typeof raw.sound_effects === 'string' && raw.sound_effects.trim() !== '') out.soundEffects = raw.sound_effects.trim()
178
+ return Object.keys(out).length > 0 ? out : undefined
179
+ })()
180
+ : undefined
181
+ const timbreWeights = Array.isArray(args.timbre_weights)
182
+ ? args.timbre_weights
183
+ .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')
184
+ .map(item => ({ voiceId: (item.voice_id as string).trim(), weight: item.weight as number }))
185
+ .filter(item => item.voiceId !== '')
186
+ : undefined
121
187
  const request: GenerateAudioRequest = {
122
- mode: args.mode === 'music' ? 'music' : args.mode === 'sfx' ? 'sfx' : 'tts',
188
+ mode,
123
189
  model: picked.alias,
124
190
  upstream: picked.upstream,
125
191
  channelId: picked.channel.id,
126
192
  channel: picked.channel.name,
127
193
  prompt: args.prompt.trim(),
128
194
  ...(typeof args.voice === 'string' && args.voice.trim() !== '' ? { voice: args.voice.trim() } : {}),
195
+ ...(typeof args.preview_text === 'string' && args.preview_text.trim() !== '' ? { previewText: args.preview_text.trim() } : {}),
129
196
  ...(typeof args.speed === 'number' ? { speed: args.speed } : {}),
130
197
  ...(typeof args.duration === 'number' ? { duration: args.duration } : {}),
131
198
  ...(typeof args.format === 'string' && args.format.trim() !== '' ? { format: args.format.trim() } : {}),
199
+ // ---- MiniMax TTS 专属字段 ----
200
+ ...(typeof args.emotion === 'string' && args.emotion.trim() !== '' ? { emotion: args.emotion.trim() } : {}),
201
+ ...(typeof args.vol === 'number' && Number.isFinite(args.vol) ? { vol: args.vol } : {}),
202
+ ...(typeof args.pitch === 'number' && Number.isFinite(args.pitch) ? { pitch: args.pitch } : {}),
203
+ ...(typeof args.text_normalization === 'boolean' ? { textNormalization: args.text_normalization } : {}),
204
+ ...(typeof args.latex_read === 'boolean' ? { latexRead: args.latex_read } : {}),
205
+ ...(Array.isArray(args.pronunciation_tone) && args.pronunciation_tone.length > 0
206
+ ? { pronunciationTone: args.pronunciation_tone.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map((item: string) => item.trim()) }
207
+ : {}),
208
+ ...(typeof args.sample_rate === 'number' && Number.isFinite(args.sample_rate) ? { sampleRate: args.sample_rate } : {}),
209
+ ...(typeof args.bitrate === 'number' && Number.isFinite(args.bitrate) ? { bitrate: args.bitrate } : {}),
210
+ ...(typeof args.channel === 'number' && Number.isFinite(args.channel) ? { audioChannel: args.channel } : {}),
211
+ ...(typeof args.force_cbr === 'boolean' ? { forceCbr: args.force_cbr } : {}),
212
+ ...(typeof args.subtitle_enable === 'boolean' ? { subtitleEnable: args.subtitle_enable } : {}),
213
+ ...(typeof args.aigc_watermark === 'boolean' ? { aigcWatermark: args.aigc_watermark } : {}),
214
+ ...(typeof args.language_boost === 'string' && args.language_boost.trim() !== '' ? { languageBoost: args.language_boost.trim() } : {}),
215
+ ...(voiceModify !== undefined ? { voiceModify } : {}),
216
+ ...(timbreWeights !== undefined && timbreWeights.length > 0 ? { timbreWeights } : {}),
132
217
  }
133
218
  try {
134
219
  const outputs = await generateAudio(picked.channel, request, exec.signal)
@@ -140,6 +225,7 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
140
225
  url: `/api/dsh-audiogen/audio/${encodeURIComponent(saved.file)}`,
141
226
  mime: saved.mime,
142
227
  bytes: saved.bytes,
228
+ ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
143
229
  })
144
230
  }
145
231
  try {
@@ -159,6 +245,7 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
159
245
  mime: audio[index]!.mime,
160
246
  bytes: audio[index]!.bytes,
161
247
  url: audio[index]!.url,
248
+ ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
162
249
  })),
163
250
  channelId: picked.channel.id,
164
251
  channel: picked.channel.name,
@@ -164,7 +164,7 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
164
164
  async function normalizeAudioResponse(
165
165
  response: Response,
166
166
  options: { apiKey: string; fallbackMime?: string },
167
- ): Promise<Array<{ data: Uint8Array; mime: string }>> {
167
+ ): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
168
168
  if (!response.ok) {
169
169
  let detail = ''
170
170
  try {
@@ -213,7 +213,7 @@ async function normalizeAudioResponse(
213
213
  return [{ data: buffer, mime: audioMime(buffer, response.headers.get('content-type') ?? contentType ?? null) }]
214
214
  }
215
215
 
216
- async function openAITTS(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
216
+ async function openAITTS(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
217
217
  const base = endpointBase(channel.apiUrl)
218
218
  const endpoint = /\/audio\/speech(\?|$)/i.test(base) ? base : `${base}/audio/speech`
219
219
  const model = (request.upstream ?? request.model) || 'tts-1'
@@ -239,7 +239,7 @@ async function openAITTS(channel: AudioChannel, request: GenerateAudioRequest, s
239
239
  return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
240
240
  }
241
241
 
242
- async function elevenLabs(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
242
+ async function elevenLabs(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
243
243
  const base = endpointBase(channel.apiUrl)
244
244
  const model = (request.upstream ?? request.model) || 'eleven_multilingual_v2'
245
245
  const voiceId = (request.voice ?? request.model ?? model).trim()
@@ -274,16 +274,171 @@ function minimaxApiBase(base: string): string {
274
274
  return /\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/v1`
275
275
  }
276
276
 
277
- async function minimax(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
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
+
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
- let endpoint: string
283
- let body: Record<string, unknown>
399
+ if (request.mode === 'voice_design') {
400
+ const endpoint = `${base}/voice_design`
401
+ const body: Record<string, unknown> = {
402
+ prompt: request.prompt,
403
+ preview_text: request.previewText ?? request.voice ?? '你好,这是新设计的音色试听。',
404
+ }
405
+ const response = await fetchWithTimeout(endpoint, {
406
+ method: 'POST',
407
+ redirect: 'error',
408
+ headers: {
409
+ authorization: `Bearer ${channel.apiKey.trim()}`,
410
+ 'content-type': 'application/json',
411
+ accept: 'application/json',
412
+ },
413
+ body: JSON.stringify(body),
414
+ signal,
415
+ }, UPSTREAM_TIMEOUT_MS)
416
+ if (!response.ok) {
417
+ const detail = await response.text().catch(() => '')
418
+ throw new AudioGenError(`MiniMax voice design API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`, 'audio-api-error')
419
+ }
420
+ const payload = await response.json() as {
421
+ voice_id?: string
422
+ trial_audio?: string
423
+ base_resp?: { status_code?: number; status_msg?: string }
424
+ }
425
+ if (payload.base_resp?.status_code !== undefined && payload.base_resp.status_code !== 0) {
426
+ throw new AudioGenError(payload.base_resp.status_msg ?? `MiniMax returned status ${payload.base_resp.status_code}`, 'audio-api-error')
427
+ }
428
+ const encoded = payload.trial_audio ?? ''
429
+ if (encoded === '') throw new AudioGenError('MiniMax voice design returned no trial audio', 'audio-empty-result')
430
+ const isHex = /^[0-9a-fA-F]+$/.test(encoded) && encoded.length % 2 === 0
431
+ const data = new Uint8Array(Buffer.from(encoded, isHex ? 'hex' : 'base64'))
432
+ return [{
433
+ data,
434
+ mime: 'audio/mpeg',
435
+ ...(payload.voice_id === undefined ? {} : { voiceId: payload.voice_id }),
436
+ }]
437
+ }
438
+
284
439
  if (request.mode === 'music') {
285
- endpoint = `${base}/music_generation`
286
- body = {
440
+ const endpoint = `${base}/music_generation`
441
+ const body: Record<string, unknown> = {
287
442
  model,
288
443
  prompt: request.prompt,
289
444
  ...(request.duration !== undefined ? { duration: request.duration } : {}),
@@ -293,26 +448,34 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
293
448
  bitrate: 256000,
294
449
  },
295
450
  }
296
- } else {
297
- endpoint = `${base}/t2a_v2`
298
- body = {
299
- model,
300
- text: request.prompt,
301
- stream: false,
302
- ...(voice === '' ? {} : { voice_setting: {
303
- voice_id: voice,
304
- ...(request.speed !== undefined ? { speed: request.speed } : {}),
305
- vol: 1,
306
- pitch: 0,
307
- } }),
308
- audio_setting: {
309
- format: request.format ?? 'mp3',
310
- sample_rate: 32000,
311
- bitrate: 128000,
451
+ const response = await fetchWithTimeout(endpoint, {
452
+ method: 'POST',
453
+ redirect: 'error',
454
+ headers: {
455
+ authorization: `Bearer ${channel.apiKey.trim()}`,
456
+ 'content-type': 'application/json',
457
+ accept: 'application/json, audio/mpeg',
312
458
  },
459
+ body: JSON.stringify(body),
460
+ signal,
461
+ }, UPSTREAM_TIMEOUT_MS)
462
+ if (!response.ok) {
463
+ const detail = await response.text().catch(() => '')
464
+ throw new AudioGenError(`MiniMax music API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`, 'audio-api-error')
313
465
  }
466
+ return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
314
467
  }
315
468
 
469
+ // ------------------------------------------------------------- TTS
470
+ const voiceId = resolveMiniMaxVoice(request)
471
+ if (voiceId === undefined) {
472
+ throw new AudioGenError(
473
+ 'MiniMax TTS 需要指定音色 voice_id(如 male-qn-qingse、female-shaonv):请在「音色」字段填写,或把音色加入渠道模型目录(alias 可任意、upstream 填 voice_id),也可点「获取可用模型」拉取账号音色列表。',
474
+ 'voice-required',
475
+ )
476
+ }
477
+ const endpoint = `${base}/t2a_v2`
478
+ const body = buildMiniMaxTTSBody(request, model, voiceId)
316
479
  const response = await fetchWithTimeout(endpoint, {
317
480
  method: 'POST',
318
481
  redirect: 'error',
@@ -324,10 +487,30 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
324
487
  body: JSON.stringify(body),
325
488
  signal,
326
489
  }, UPSTREAM_TIMEOUT_MS)
327
- return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
490
+ if (response.ok) {
491
+ return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
492
+ }
493
+ const detail = await response.text().catch(() => '')
494
+ const routeMiss = response.status === 404 && /invalid url|invalid_request_error/i.test(detail)
495
+ if (!routeMiss) {
496
+ throw new AudioGenError(`MiniMax TTS API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`, 'audio-api-error')
497
+ }
498
+ // Gateway does not route the native MiniMax path — retry over its
499
+ // OpenAI-compatible /v1/audio/speech (new-api MiniMax relays merge
500
+ // `metadata` back into a full t2a_v2 request).
501
+ try {
502
+ return await minimaxTTSGateway(channel, request, signal, voiceId)
503
+ } catch (gatewayError) {
504
+ const detailText = gatewayError instanceof AudioGenError ? gatewayError.message : String(gatewayError)
505
+ throw new AudioGenError(
506
+ `MiniMax 渠道「${channel.name}」网关未提供原生 TTS 接口:POST ${endpoint} 返回 HTTP 404(Invalid URL,网关未路由 /v1/t2a_v2);已回退 OpenAI 兼容 ${minimaxApiBase(channel.apiUrl)}/audio/speech 仍失败:${detailText.slice(0, 300)}。`
507
+ + '请把渠道 API 地址配置为官方 https://api.minimaxi.com(配合 MiniMax 官方密钥),或确认网关已将 /v1/audio/speech 映射到 MiniMax 音色渠道。',
508
+ 'audio-api-error',
509
+ )
510
+ }
328
511
  }
329
512
 
330
- async function stabilityAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
513
+ async function stabilityAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
331
514
  const base = endpointBase(channel.apiUrl)
332
515
  const endpoint = /\/generation(\?|$)/i.test(base) ? base : `${base}/generation`
333
516
  const model = (request.upstream ?? request.model) || 'stable-audio-2.0'
@@ -351,7 +534,7 @@ async function stabilityAudio(channel: AudioChannel, request: GenerateAudioReque
351
534
  return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
352
535
  }
353
536
 
354
- async function genericAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string }>> {
537
+ async function genericAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
355
538
  const base = endpointBase(channel.apiUrl)
356
539
  if (request.mode === 'tts' && !/\/generate(\?|$)/i.test(base)) {
357
540
  return openAITTS(channel, request, signal)
@@ -388,10 +571,13 @@ export async function generateAudio(
388
571
  channel: AudioChannel,
389
572
  request: GenerateAudioRequest,
390
573
  signal?: AbortSignal,
391
- ): Promise<Array<{ data: Uint8Array; mime: string }>> {
574
+ ): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
392
575
  if (channel.apiUrl.trim() === '') throw new AudioGenError('channel API URL is not configured', 'audio-no-endpoint')
393
576
  if (channel.apiKey.trim() === '') throw new AudioGenError('channel API key is not configured', 'audio-no-key')
394
577
  if (request.prompt.trim() === '') throw new AudioGenError('audio prompt/text is required', 'audio-empty-prompt')
578
+ if (request.mode === 'voice_design' && !isMiniMax(channel)) {
579
+ throw new AudioGenError('音色设计当前仅支持 MiniMax 渠道', 'voice-design-unsupported')
580
+ }
395
581
 
396
582
  if (isElevenLabs(channel)) return elevenLabs(channel, request, signal)
397
583
  if (isMiniMax(channel)) return minimax(channel, request, signal)