dsh-audiogen 0.3.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/lib/client.js +1046 -546
- package/lib/client.js.map +1 -1
- package/lib/index.js +445 -114
- package/package.json +1 -1
- package/skills/tts/SKILL.md +42 -4
- package/src/agent-audio-tools.ts +75 -2
- package/src/audio-engine.ts +165 -22
- package/src/audio-models.ts +132 -24
- package/src/audio-presets.ts +27 -33
- package/src/client/AudioGenPanel.tsx +69 -1
- package/src/client/SettingsCard.tsx +520 -176
- package/src/client/audio-panel.module.css +36 -0
- package/src/client/locales.ts +84 -16
- package/src/client/settings-card.module.css +424 -607
- package/src/protocol.ts +35 -1
- package/src/routes.ts +42 -3
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.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/skills/tts/SKILL.md
CHANGED
|
@@ -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 不受该音色支持)。
|
package/src/agent-audio-tools.ts
CHANGED
|
@@ -105,11 +105,49 @@ 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
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
|
+
},
|
|
113
151
|
},
|
|
114
152
|
output: {
|
|
115
153
|
schema: resultSchema,
|
|
@@ -129,6 +167,23 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
|
|
|
129
167
|
return { channel: target, alias: '', upstream: '' }
|
|
130
168
|
})()
|
|
131
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
|
|
132
187
|
const request: GenerateAudioRequest = {
|
|
133
188
|
mode,
|
|
134
189
|
model: picked.alias,
|
|
@@ -141,6 +196,24 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
|
|
|
141
196
|
...(typeof args.speed === 'number' ? { speed: args.speed } : {}),
|
|
142
197
|
...(typeof args.duration === 'number' ? { duration: args.duration } : {}),
|
|
143
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 } : {}),
|
|
144
217
|
}
|
|
145
218
|
try {
|
|
146
219
|
const outputs = await generateAudio(picked.channel, request, exec.signal)
|
package/src/audio-engine.ts
CHANGED
|
@@ -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,11 +436,9 @@ 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
|
+
const endpoint = `${base}/music_generation`
|
|
441
|
+
const body: Record<string, unknown> = {
|
|
327
442
|
model,
|
|
328
443
|
prompt: request.prompt,
|
|
329
444
|
...(request.duration !== undefined ? { duration: request.duration } : {}),
|
|
@@ -333,26 +448,34 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
|
|
|
333
448
|
bitrate: 256000,
|
|
334
449
|
},
|
|
335
450
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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,
|
|
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',
|
|
352
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')
|
|
353
465
|
}
|
|
466
|
+
return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
|
|
354
467
|
}
|
|
355
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)
|
|
356
479
|
const response = await fetchWithTimeout(endpoint, {
|
|
357
480
|
method: 'POST',
|
|
358
481
|
redirect: 'error',
|
|
@@ -364,7 +487,27 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
|
|
|
364
487
|
body: JSON.stringify(body),
|
|
365
488
|
signal,
|
|
366
489
|
}, UPSTREAM_TIMEOUT_MS)
|
|
367
|
-
|
|
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
|
+
}
|
|
368
511
|
}
|
|
369
512
|
|
|
370
513
|
async function stabilityAudio(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
|
package/src/audio-models.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host-side model/voice discovery.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Each vendor answers differently:
|
|
5
|
+
* - MiniMax exposes a voice-management API (/v1/get_voice) that returns all
|
|
6
|
+
* system and user-generated voice ids; the known MiniMax music models are
|
|
7
|
+
* appended so the settings card can offer a full categorized catalog.
|
|
8
|
+
* - ElevenLabs exposes /v1/models and /v1/voices; only models that can
|
|
9
|
+
* actually speak (text_to_speech capability) and account voices are kept.
|
|
10
|
+
* - Stability AI has no listing endpoint; the built-in stable-audio catalog
|
|
11
|
+
* is returned.
|
|
12
|
+
* - Generic OpenAI-compatible endpoints answer /models; the reply is filtered
|
|
13
|
+
* to audio-related model ids only (tts / music / sfx), never the whole
|
|
14
|
+
* model list of a gateway.
|
|
7
15
|
*/
|
|
8
16
|
|
|
9
17
|
import type { AudioChannel } from './audio-engine.ts'
|
|
@@ -14,20 +22,38 @@ function isMiniMax(channel: AudioChannel): boolean {
|
|
|
14
22
|
return channel.preset === 'minimax' || /minimax/i.test(channel.apiUrl)
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
function isElevenLabs(channel: AudioChannel): boolean {
|
|
26
|
+
return channel.preset === 'elevenlabs' || /elevenlabs/i.test(channel.apiUrl)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isStability(channel: AudioChannel): boolean {
|
|
30
|
+
return channel.preset === 'stability' || /stability\.ai/i.test(channel.apiUrl)
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
function baseUrl(url: string): string {
|
|
18
34
|
return url.trim().replace(/\/+$/, '')
|
|
19
35
|
}
|
|
20
36
|
|
|
37
|
+
/** Whether an upstream model id is audio-related at all. */
|
|
21
38
|
function categoryFor(id: string): AudioModelCategory | undefined {
|
|
22
39
|
const value = id.toLowerCase()
|
|
23
|
-
if (/(tts|speech|voice|t2a)/i.test(value)) return 'tts'
|
|
24
|
-
if (/(music|song|cover|lyrics)/i.test(value)) return 'music'
|
|
40
|
+
if (/(tts|speech|voice|t2a|talk|narration)/i.test(value)) return 'tts'
|
|
41
|
+
if (/(music|song|cover|lyrics|audio|melody|beat)/i.test(value)) return 'music'
|
|
25
42
|
if (/(sfx|sound.?effect|effect|foley)/i.test(value)) return 'sfx'
|
|
26
43
|
return undefined
|
|
27
44
|
}
|
|
28
45
|
|
|
46
|
+
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
|
47
|
+
const response = await fetch(url, init)
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const text = await response.text().catch(() => '')
|
|
50
|
+
throw new Error(`HTTP ${response.status}${text === '' ? '' : `: ${text.slice(0, 300)}`}`)
|
|
51
|
+
}
|
|
52
|
+
return response.json()
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
async function postJson(url: string, apiKey: string, body: unknown): Promise<unknown> {
|
|
30
|
-
|
|
56
|
+
return fetchJson(url, {
|
|
31
57
|
method: 'POST',
|
|
32
58
|
headers: {
|
|
33
59
|
authorization: `Bearer ${apiKey.trim()}`,
|
|
@@ -35,11 +61,6 @@ async function postJson(url: string, apiKey: string, body: unknown): Promise<unk
|
|
|
35
61
|
},
|
|
36
62
|
body: JSON.stringify(body),
|
|
37
63
|
})
|
|
38
|
-
if (!response.ok) {
|
|
39
|
-
const text = await response.text().catch(() => '')
|
|
40
|
-
throw new Error(`HTTP ${response.status}${text === '' ? '' : `: ${text.slice(0, 300)}`}`)
|
|
41
|
-
}
|
|
42
|
-
return response.json()
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
/** Discover available models/voices for a channel. */
|
|
@@ -47,9 +68,18 @@ export async function discoverAudioModels(channel: AudioChannel): Promise<{ mode
|
|
|
47
68
|
if (channel.apiUrl.trim() === '') throw new Error('API URL is not configured')
|
|
48
69
|
if (channel.apiKey.trim() === '') throw new Error('API key is not configured')
|
|
49
70
|
|
|
50
|
-
if (isMiniMax(channel))
|
|
51
|
-
|
|
52
|
-
|
|
71
|
+
if (isMiniMax(channel)) return discoverMiniMax(channel)
|
|
72
|
+
if (isElevenLabs(channel)) return discoverElevenLabs(channel)
|
|
73
|
+
if (isStability(channel)) return discoverStability(channel)
|
|
74
|
+
return discoverOpenAICompatible(channel)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------- MiniMax
|
|
78
|
+
|
|
79
|
+
async function discoverMiniMax(channel: AudioChannel): Promise<{ models: DiscoveredAudioModel[]; source: string }> {
|
|
80
|
+
const base = baseUrl(channel.apiUrl).replace(/\/v1$/i, '')
|
|
81
|
+
const url = `${base}/v1/get_voice`
|
|
82
|
+
try {
|
|
53
83
|
const payload = await postJson(url, channel.apiKey, { voice_type: 'all' }) as {
|
|
54
84
|
system_voice?: Array<{ voice_id?: string; voice_name?: string; description?: string[] }>
|
|
55
85
|
voice_cloning?: Array<{ voice_id?: string; description?: string[] }>
|
|
@@ -94,27 +124,105 @@ export async function discoverAudioModels(channel: AudioChannel): Promise<{ mode
|
|
|
94
124
|
const music = (audioPresetById('minimax')?.models ?? []).filter(model => model.category === 'music')
|
|
95
125
|
for (const model of music) models.push({ ...model, category: 'music' as const })
|
|
96
126
|
const deduped = dedupe(models)
|
|
97
|
-
return { models: deduped, source: 'MiniMax get_voice +
|
|
127
|
+
return { models: deduped, source: 'MiniMax get_voice + music 目录' }
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// Gateways may not route /v1/get_voice (or lack voice-management access).
|
|
130
|
+
// Fall back to the built-in catalog so generation still works.
|
|
131
|
+
const fallback = (audioPresetById('minimax')?.models ?? []).map(model => ({ ...model }))
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
133
|
+
return {
|
|
134
|
+
models: dedupe(fallback),
|
|
135
|
+
source: `内置 MiniMax 目录(音色发现失败:${message.slice(0, 160)})`,
|
|
136
|
+
}
|
|
98
137
|
}
|
|
138
|
+
}
|
|
99
139
|
|
|
100
|
-
|
|
140
|
+
// ------------------------------------------------------------- ElevenLabs
|
|
141
|
+
|
|
142
|
+
async function discoverElevenLabs(channel: AudioChannel): Promise<{ models: DiscoveredAudioModel[]; source: string }> {
|
|
143
|
+
const base = baseUrl(channel.apiUrl)
|
|
144
|
+
const headers = { 'xi-api-key': channel.apiKey.trim() }
|
|
145
|
+
const failures: string[] = []
|
|
146
|
+
const models: DiscoveredAudioModel[] = []
|
|
147
|
+
|
|
148
|
+
// 1. TTS-capable models from /v1/models (audio-related only).
|
|
149
|
+
try {
|
|
150
|
+
const payload = await fetchJson(`${base}/models`, { headers }) as Array<{
|
|
151
|
+
model_id?: string
|
|
152
|
+
name?: string
|
|
153
|
+
description?: string
|
|
154
|
+
capabilities?: { text_to_speech?: boolean; voice_change?: boolean; speech_to_text?: boolean }
|
|
155
|
+
}>
|
|
156
|
+
for (const item of Array.isArray(payload) ? payload : []) {
|
|
157
|
+
const id = item.model_id?.trim() ?? ''
|
|
158
|
+
if (id === '') continue
|
|
159
|
+
// Only what actually produces speech audio.
|
|
160
|
+
if (item.capabilities?.text_to_speech !== true && item.capabilities?.voice_change !== true) continue
|
|
161
|
+
models.push({
|
|
162
|
+
alias: item.name?.trim() || id,
|
|
163
|
+
id,
|
|
164
|
+
category: 'tts',
|
|
165
|
+
...(item.description !== undefined && item.description.trim() !== '' ? { description: item.description.trim() } : {}),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
failures.push(`模型列表:${error instanceof Error ? error.message : String(error)}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 2. The account's voices from /v1/voices, grouped as tts entries.
|
|
173
|
+
try {
|
|
174
|
+
const payload = await fetchJson(`${base}/voices`, { headers }) as {
|
|
175
|
+
voices?: Array<{ voice_id?: string; name?: string; description?: string }>
|
|
176
|
+
}
|
|
177
|
+
for (const voice of Array.isArray(payload?.voices) ? payload.voices : []) {
|
|
178
|
+
const id = voice.voice_id?.trim() ?? ''
|
|
179
|
+
if (id === '') continue
|
|
180
|
+
models.push({
|
|
181
|
+
alias: voice.name?.trim() || id,
|
|
182
|
+
id,
|
|
183
|
+
category: 'tts',
|
|
184
|
+
...(voice.description !== undefined && voice.description.trim() !== '' ? { description: voice.description.trim() } : {}),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
failures.push(`音色列表:${error instanceof Error ? error.message : String(error)}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (models.length === 0) {
|
|
192
|
+
// Neither endpoint answered — fall back to the built-in catalog.
|
|
193
|
+
const fallback = (audioPresetById('elevenlabs')?.models ?? []).map(model => ({ ...model }))
|
|
194
|
+
const detail = failures.length === 0 ? '' : `(发现失败:${failures.join(';').slice(0, 160)})`
|
|
195
|
+
return { models: dedupe(fallback), source: `内置 ElevenLabs 目录${detail}` }
|
|
196
|
+
}
|
|
197
|
+
return { models: dedupe(models), source: 'ElevenLabs /models + /voices' }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// -------------------------------------------------------------- Stability
|
|
201
|
+
|
|
202
|
+
async function discoverStability(channel: AudioChannel): Promise<{ models: DiscoveredAudioModel[]; source: string }> {
|
|
203
|
+
// Stability has no public audio model listing; serve the built-in catalog.
|
|
204
|
+
const fallback = (audioPresetById('stability-audio')?.models ?? []).map(model => ({ ...model }))
|
|
205
|
+
return { models: dedupe(fallback), source: 'Stability stable-audio 内置目录' }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ------------------------------------------------------ OpenAI-compatible
|
|
209
|
+
|
|
210
|
+
async function discoverOpenAICompatible(channel: AudioChannel): Promise<{ models: DiscoveredAudioModel[]; source: string }> {
|
|
101
211
|
const base = baseUrl(channel.apiUrl)
|
|
102
212
|
const url = `${base}/models`
|
|
103
|
-
const
|
|
213
|
+
const payload = await fetchJson(url, {
|
|
104
214
|
headers: { authorization: `Bearer ${channel.apiKey.trim()}` },
|
|
105
|
-
})
|
|
106
|
-
if (!response.ok) {
|
|
107
|
-
throw new Error(`model list request failed (HTTP ${response.status}); please add models manually`)
|
|
108
|
-
}
|
|
109
|
-
const payload = await response.json() as { data?: Array<{ id?: string }> }
|
|
215
|
+
}) as { data?: Array<{ id?: string }> }
|
|
110
216
|
const models: DiscoveredAudioModel[] = []
|
|
111
217
|
for (const item of payload.data ?? []) {
|
|
112
218
|
const id = item.id?.trim() ?? ''
|
|
113
219
|
if (id === '') continue
|
|
114
|
-
|
|
220
|
+
// Audio-related models only — never the whole gateway model list.
|
|
221
|
+
const category = categoryFor(id)
|
|
222
|
+
if (category === undefined) continue
|
|
115
223
|
models.push({ alias: id, id, category })
|
|
116
224
|
}
|
|
117
|
-
return { models: dedupe(models), source: 'OpenAI-compatible /models' }
|
|
225
|
+
return { models: dedupe(models), source: 'OpenAI-compatible /models(仅音频相关)' }
|
|
118
226
|
}
|
|
119
227
|
|
|
120
228
|
function dedupe(models: DiscoveredAudioModel[]): DiscoveredAudioModel[] {
|