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/lib/client.js +1101 -573
- package/lib/client.js.map +1 -1
- package/lib/index.js +528 -125
- package/package.json +1 -1
- package/skills/tts/SKILL.md +42 -4
- package/src/agent-audio-tools.ts +94 -7
- package/src/audio-engine.ts +215 -29
- package/src/audio-models.ts +132 -24
- package/src/audio-presets.ts +27 -33
- package/src/audio-store.ts +2 -0
- package/src/client/AudioGenPanel.tsx +140 -36
- package/src/client/SettingsCard.tsx +520 -176
- package/src/client/audio-panel.module.css +36 -0
- package/src/client/locales.ts +88 -18
- package/src/client/settings-card.module.css +424 -607
- package/src/protocol.ts +44 -2
- package/src/routes.ts +49 -4
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[] {
|
package/src/audio-presets.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in audio provider catalog (presets).
|
|
3
3
|
* Framework-free pure data; served to the settings card through a host route.
|
|
4
|
+
*
|
|
5
|
+
* Only the officially supported audio vendors are offered here — MiniMax,
|
|
6
|
+
* ElevenLabs and Stability AI. Any other endpoint (including OpenAI-compatible
|
|
7
|
+
* TTS gateways) is added through the "自定义渠道" flow instead.
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import type { ModelMapping } from './protocol.ts'
|
|
@@ -15,39 +19,19 @@ export interface AudioPresetProvider {
|
|
|
15
19
|
apiUrl: string
|
|
16
20
|
/** One-line description shown in the picker. */
|
|
17
21
|
hint: string
|
|
22
|
+
/** Official vendor website, shown as a link in the channel editor. */
|
|
23
|
+
site?: string
|
|
18
24
|
/** Known model/voice catalog prefilled into the channel. */
|
|
19
25
|
models: ModelMapping[]
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export const AUDIO_PRESETS: AudioPresetProvider[] = [
|
|
23
|
-
{
|
|
24
|
-
id: 'openai-tts',
|
|
25
|
-
name: 'OpenAI · TTS',
|
|
26
|
-
apiUrl: 'https://api.openai.com/v1',
|
|
27
|
-
hint: 'OpenAI 官方语音合成接口(/audio/speech)',
|
|
28
|
-
models: [
|
|
29
|
-
{ alias: 'tts-1', id: 'tts-1', category: 'tts' },
|
|
30
|
-
{ alias: 'tts-1-hd', id: 'tts-1-hd', category: 'tts' },
|
|
31
|
-
{ alias: 'gpt-4o-mini-tts', id: 'gpt-4o-mini-tts', category: 'tts' },
|
|
32
|
-
],
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
id: 'elevenlabs',
|
|
36
|
-
name: 'ElevenLabs',
|
|
37
|
-
apiUrl: 'https://api.elevenlabs.io/v1',
|
|
38
|
-
hint: 'ElevenLabs TTS;模型列表请填写你的 Voice ID(如 Rachel / Adam 等别名)',
|
|
39
|
-
models: [
|
|
40
|
-
{ alias: 'Rachel', id: '21m00Tcm4TlvDq8ikWAM', category: 'tts' },
|
|
41
|
-
{ alias: 'Adam', id: 'pNInz6obpgDQGcFmaJgB', category: 'tts' },
|
|
42
|
-
{ alias: 'Antoni', id: 'ErXwobaYiN019PkySvjV', category: 'tts' },
|
|
43
|
-
{ alias: 'Bella', id: 'EXAVITQu4vr4xnSDxMaL', category: 'tts' },
|
|
44
|
-
],
|
|
45
|
-
},
|
|
46
29
|
{
|
|
47
30
|
id: 'minimax',
|
|
48
31
|
name: 'MiniMax',
|
|
49
32
|
apiUrl: 'https://api.minimaxi.com',
|
|
50
|
-
|
|
33
|
+
site: 'https://www.minimaxi.com',
|
|
34
|
+
hint: 'MiniMax 官方音频:音色设计 / TTS / 音乐生成;建议点击「获取可用模型」拉取账号音色与模型',
|
|
51
35
|
models: [
|
|
52
36
|
// TTS models
|
|
53
37
|
{ alias: 'speech-2.8-hd', id: 'speech-2.8-hd', category: 'tts' },
|
|
@@ -64,23 +48,33 @@ export const AUDIO_PRESETS: AudioPresetProvider[] = [
|
|
|
64
48
|
{ alias: 'music-cover', id: 'music-cover', category: 'music' },
|
|
65
49
|
],
|
|
66
50
|
},
|
|
51
|
+
{
|
|
52
|
+
id: 'elevenlabs',
|
|
53
|
+
name: 'ElevenLabs',
|
|
54
|
+
apiUrl: 'https://api.elevenlabs.io/v1',
|
|
55
|
+
site: 'https://elevenlabsai.cn',
|
|
56
|
+
hint: 'ElevenLabs 语音合成(TTS);建议点击「获取可用模型」拉取音色与模型',
|
|
57
|
+
models: [
|
|
58
|
+
{ alias: 'Rachel', id: '21m00Tcm4TlvDq8ikWAM', category: 'tts' },
|
|
59
|
+
{ alias: 'Adam', id: 'pNInz6obpgDQGcFmaJgB', category: 'tts' },
|
|
60
|
+
{ alias: 'Antoni', id: 'ErXwobaYiN019PkySvjV', category: 'tts' },
|
|
61
|
+
{ alias: 'Bella', id: 'EXAVITQu4vr4xnSDxMaL', category: 'tts' },
|
|
62
|
+
{ alias: 'eleven_multilingual_v2', id: 'eleven_multilingual_v2', category: 'tts' },
|
|
63
|
+
{ alias: 'eleven_turbo_v2_5', id: 'eleven_turbo_v2_5', category: 'tts' },
|
|
64
|
+
{ alias: 'eleven_flash_v2_5', id: 'eleven_flash_v2_5', category: 'tts' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
67
|
{
|
|
68
68
|
id: 'stability-audio',
|
|
69
|
-
name: 'Stability AI
|
|
69
|
+
name: 'Stability AI(stable-audio)',
|
|
70
70
|
apiUrl: 'https://api.stability.ai/v2beta/audio',
|
|
71
|
-
|
|
71
|
+
site: 'https://stability.ai/stable-audio',
|
|
72
|
+
hint: 'Stability AI 音乐 / 音效生成(stable-audio 系列)',
|
|
72
73
|
models: [
|
|
73
74
|
{ alias: 'stable-audio-2.0', id: 'stable-audio-2.0', category: 'music' },
|
|
74
75
|
{ alias: 'stable-audio-1.0', id: 'stable-audio-1.0', category: 'music' },
|
|
75
76
|
],
|
|
76
77
|
},
|
|
77
|
-
{
|
|
78
|
-
id: 'custom',
|
|
79
|
-
name: '自定义渠道',
|
|
80
|
-
apiUrl: '',
|
|
81
|
-
hint: '任意兼容接口;支持 OpenAI 兼容 TTS,或返回音频字节 / JSON 的通用 POST',
|
|
82
|
-
models: [],
|
|
83
|
-
},
|
|
84
78
|
]
|
|
85
79
|
|
|
86
80
|
/** Look up one built-in provider by id. */
|
package/src/audio-store.ts
CHANGED
|
@@ -99,6 +99,7 @@ export async function appendHistory(entry: HistoryEntryInput): Promise<HistoryEn
|
|
|
99
99
|
model: entry.model,
|
|
100
100
|
prompt: entry.prompt,
|
|
101
101
|
...(entry.voice === undefined ? {} : { voice: entry.voice }),
|
|
102
|
+
...(entry.voiceId === undefined ? {} : { voiceId: entry.voiceId }),
|
|
102
103
|
...(entry.speed === undefined ? {} : { speed: entry.speed }),
|
|
103
104
|
...(entry.duration === undefined ? {} : { duration: entry.duration }),
|
|
104
105
|
...(entry.format === undefined ? {} : { format: entry.format }),
|
|
@@ -106,6 +107,7 @@ export async function appendHistory(entry: HistoryEntryInput): Promise<HistoryEn
|
|
|
106
107
|
url: audio.url,
|
|
107
108
|
mime: audio.mime,
|
|
108
109
|
...(audio.duration === undefined ? {} : { duration: audio.duration }),
|
|
110
|
+
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
109
111
|
})),
|
|
110
112
|
...(entry.channelId === undefined ? {} : { channelId: entry.channelId }),
|
|
111
113
|
...(entry.channel === undefined ? {} : { channel: entry.channel }),
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
|
|
1
2
|
/**
|
|
2
3
|
* The AI 音频 panel: a compact audio-generation studio.
|
|
4
|
+
* TTS / music / SFX / voice design are separated; each mode only lists
|
|
5
|
+
* compatible models and shows its own parameters.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import { useEffect, useMemo, useState } from 'react'
|
|
@@ -7,7 +10,7 @@ import type { AudiogenApi } from './api.ts'
|
|
|
7
10
|
import type { AudiogenScope } from './settings-scope.ts'
|
|
8
11
|
import { audioModelOptions } from './settings-scope.ts'
|
|
9
12
|
import { tt } from './helpers.ts'
|
|
10
|
-
import {
|
|
13
|
+
import { HISTORY_API, type AudioMode, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
|
|
11
14
|
import css from './audio-panel.module.css'
|
|
12
15
|
|
|
13
16
|
function useConfig(scope: AudiogenScope) {
|
|
@@ -47,24 +50,42 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
47
50
|
const channels = config?.channels ?? []
|
|
48
51
|
const connected = enabled && channels.some(channel => {
|
|
49
52
|
const keyHeld = scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)
|
|
50
|
-
return channel.apiUrl.trim() !== '' && keyHeld && channel.models.length > 0
|
|
53
|
+
return channel.apiUrl.trim() !== '' && keyHeld && (channel.models.length > 0 || channel.preset === 'minimax')
|
|
51
54
|
})
|
|
52
55
|
|
|
53
56
|
const [mode, setMode] = useState<AudioMode>('tts')
|
|
54
57
|
const [prompt, setPrompt] = useState('')
|
|
58
|
+
const [previewText, setPreviewText] = useState('')
|
|
55
59
|
const [model, setModel] = useState('')
|
|
56
60
|
const [voice, setVoice] = useState('')
|
|
57
61
|
const [speed, setSpeed] = useState('')
|
|
58
62
|
const [duration, setDuration] = useState('')
|
|
59
63
|
const [format, setFormat] = useState('mp3')
|
|
64
|
+
// MiniMax TTS 高级参数(其他厂商忽略)
|
|
65
|
+
const [emotion, setEmotion] = useState('')
|
|
66
|
+
const [vol, setVol] = useState('')
|
|
67
|
+
const [pitch, setPitch] = useState('')
|
|
68
|
+
const [toneText, setToneText] = useState('')
|
|
69
|
+
const [sampleRate, setSampleRate] = useState('')
|
|
70
|
+
const [bitrate, setBitrate] = useState('')
|
|
71
|
+
const [audioChannel, setAudioChannel] = useState('')
|
|
72
|
+
const [subtitle, setSubtitle] = useState(false)
|
|
60
73
|
const [loading, setLoading] = useState(false)
|
|
61
74
|
const [error, setError] = useState<string | null>(null)
|
|
62
75
|
const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
|
|
63
76
|
const { entries, reload, clear } = useHistory()
|
|
64
77
|
|
|
65
|
-
const
|
|
66
|
-
.
|
|
67
|
-
|
|
78
|
+
const isMiniMaxChannel = useMemo(() => {
|
|
79
|
+
const target = channels.find(candidate => candidate.id === modelOptions.defaultChannelId) ?? channels[0]
|
|
80
|
+
return target !== undefined && (target.preset === 'minimax' || /minimax/i.test(target.apiUrl))
|
|
81
|
+
}, [channels, modelOptions.defaultChannelId])
|
|
82
|
+
|
|
83
|
+
const visibleModels = useMemo(() => {
|
|
84
|
+
if (mode === 'voice_design') return []
|
|
85
|
+
return modelOptions.models
|
|
86
|
+
.filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
|
|
87
|
+
.map(entry => entry.alias)
|
|
88
|
+
}, [modelOptions.models, mode])
|
|
68
89
|
|
|
69
90
|
useEffect(() => {
|
|
70
91
|
if (visibleModels.length > 0 && !visibleModels.includes(model)) {
|
|
@@ -84,10 +105,19 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
84
105
|
mode,
|
|
85
106
|
model: (model || visibleModels[0]) ?? '',
|
|
86
107
|
prompt: prompt.trim(),
|
|
108
|
+
...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
|
|
87
109
|
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
88
110
|
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
89
111
|
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
90
112
|
...(format.trim() !== '' ? { format: format.trim() } : {}),
|
|
113
|
+
...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
|
|
114
|
+
...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
|
|
115
|
+
...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
|
|
116
|
+
...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
|
|
117
|
+
...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
|
|
118
|
+
...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
|
|
119
|
+
...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
|
|
120
|
+
...(subtitle ? { subtitleEnable: true } : {}),
|
|
91
121
|
})
|
|
92
122
|
if (!response.ok) {
|
|
93
123
|
setError(response.message ?? '生成失败')
|
|
@@ -105,9 +135,12 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
105
135
|
const modeLabel = useMemo(() => {
|
|
106
136
|
if (mode === 'tts') return tt('mode.tts')
|
|
107
137
|
if (mode === 'music') return tt('mode.music')
|
|
108
|
-
return tt('mode.sfx')
|
|
138
|
+
if (mode === 'sfx') return tt('mode.sfx')
|
|
139
|
+
return tt('mode.voiceDesign')
|
|
109
140
|
}, [mode])
|
|
110
141
|
|
|
142
|
+
const needModel = mode !== 'voice_design'
|
|
143
|
+
|
|
111
144
|
return (
|
|
112
145
|
<div className={css.panel}>
|
|
113
146
|
<header className={css.header}>
|
|
@@ -117,7 +150,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
117
150
|
<div className={css.layout}>
|
|
118
151
|
<div className={css.form}>
|
|
119
152
|
<div className={css.modeRow}>
|
|
120
|
-
{(['tts', 'music', 'sfx'] as const).map(item => (
|
|
153
|
+
{(['tts', 'music', 'sfx', 'voice_design'] as const).map(item => (
|
|
121
154
|
<button
|
|
122
155
|
key={item}
|
|
123
156
|
type="button"
|
|
@@ -125,50 +158,119 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
125
158
|
data-active={mode === item ? 'true' : 'false'}
|
|
126
159
|
onClick={() => setMode(item)}
|
|
127
160
|
>
|
|
128
|
-
{item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : tt('mode.sfx')}
|
|
161
|
+
{item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : item === 'sfx' ? tt('mode.sfx') : tt('mode.voiceDesign')}
|
|
129
162
|
</button>
|
|
130
163
|
))}
|
|
131
164
|
</div>
|
|
165
|
+
|
|
132
166
|
<label className={css.label}>
|
|
133
|
-
<span>{mode === 'tts' ? '文本' : '提示词'}</span>
|
|
167
|
+
<span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
|
|
134
168
|
<textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
|
|
135
169
|
</label>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
</
|
|
142
|
-
|
|
170
|
+
|
|
171
|
+
{mode === 'voice_design' ? (
|
|
172
|
+
<label className={css.label}>
|
|
173
|
+
<span>试听文本</span>
|
|
174
|
+
<input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
|
|
175
|
+
</label>
|
|
176
|
+
) : null}
|
|
177
|
+
|
|
178
|
+
{needModel ? (
|
|
179
|
+
<label className={css.label}>
|
|
180
|
+
<span>{tt('model.label')}</span>
|
|
181
|
+
<select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
|
|
182
|
+
{visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
|
|
183
|
+
{visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
|
|
184
|
+
</select>
|
|
185
|
+
</label>
|
|
186
|
+
) : null}
|
|
187
|
+
|
|
143
188
|
{mode === 'tts' ? (
|
|
144
189
|
<label className={css.label}>
|
|
145
190
|
<span>{tt('voice.label')}</span>
|
|
146
|
-
<input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder=
|
|
191
|
+
<input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
|
|
147
192
|
</label>
|
|
148
193
|
) : null}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
194
|
+
|
|
195
|
+
{mode === 'tts' ? (
|
|
196
|
+
<label className={css.label}>
|
|
197
|
+
<span>{tt('speed.label')}</span>
|
|
198
|
+
<input className={css.input} type="number" step="0.1" min="0.5" max="2" value={speed} onChange={event => setSpeed(event.target.value)} placeholder="1.0" />
|
|
199
|
+
</label>
|
|
200
|
+
) : null}
|
|
201
|
+
|
|
202
|
+
{mode === 'tts' && isMiniMaxChannel ? (
|
|
203
|
+
<details className={css.advanced}>
|
|
204
|
+
<summary>MiniMax 高级参数</summary>
|
|
205
|
+
<label className={css.label}>
|
|
206
|
+
<span>情绪 emotion</span>
|
|
207
|
+
<input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
|
|
208
|
+
</label>
|
|
209
|
+
<div className={css.row}>
|
|
210
|
+
<label className={css.label}>
|
|
211
|
+
<span>音量 vol (0-10)</span>
|
|
212
|
+
<input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
|
|
213
|
+
</label>
|
|
214
|
+
<label className={css.label}>
|
|
215
|
+
<span>音调 pitch (-12~12)</span>
|
|
216
|
+
<input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
|
|
217
|
+
</label>
|
|
218
|
+
</div>
|
|
219
|
+
<div className={css.row}>
|
|
220
|
+
<label className={css.label}>
|
|
221
|
+
<span>采样率</span>
|
|
222
|
+
<input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
|
|
223
|
+
</label>
|
|
224
|
+
<label className={css.label}>
|
|
225
|
+
<span>码率 bps</span>
|
|
226
|
+
<input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
|
|
227
|
+
</label>
|
|
228
|
+
<label className={css.label}>
|
|
229
|
+
<span>声道</span>
|
|
230
|
+
<select className={css.select} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
|
|
231
|
+
<option value="">默认(1)</option>
|
|
232
|
+
<option value="1">1</option>
|
|
233
|
+
<option value="2">2</option>
|
|
234
|
+
</select>
|
|
235
|
+
</label>
|
|
236
|
+
</div>
|
|
237
|
+
<label className={css.label}>
|
|
238
|
+
<span>发音词典(每行一条:"文字/读音")</span>
|
|
239
|
+
<textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
|
|
240
|
+
</label>
|
|
241
|
+
<label className={css.checkbox}>
|
|
242
|
+
<input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
|
|
243
|
+
<span>生成字幕 subtitle_enable</span>
|
|
244
|
+
</label>
|
|
245
|
+
</details>
|
|
246
|
+
) : null}
|
|
247
|
+
|
|
248
|
+
{mode === 'music' || mode === 'sfx' ? (
|
|
249
|
+
<label className={css.label}>
|
|
250
|
+
<span>{tt('duration.label')}</span>
|
|
251
|
+
<input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
|
|
252
|
+
</label>
|
|
253
|
+
) : null}
|
|
254
|
+
|
|
255
|
+
{needModel ? (
|
|
256
|
+
<label className={css.label}>
|
|
257
|
+
<span>{tt('format.label')}</span>
|
|
258
|
+
<select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
|
|
259
|
+
<option value="mp3">mp3</option>
|
|
260
|
+
<option value="wav">wav</option>
|
|
261
|
+
<option value="flac">flac</option>
|
|
262
|
+
<option value="ogg">ogg</option>
|
|
263
|
+
<option value="pcm">pcm</option>
|
|
264
|
+
</select>
|
|
265
|
+
</label>
|
|
266
|
+
) : null}
|
|
267
|
+
|
|
167
268
|
{!connected && <p className={css.hint}>{tt('config.missing')}</p>}
|
|
168
|
-
<button type="button" className={css.generate} disabled={loading || !connected || visibleModels.length === 0} onClick={() => void submit()}>
|
|
269
|
+
<button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
|
|
169
270
|
{loading ? tt('generating') : tt('generate')}
|
|
170
271
|
</button>
|
|
171
272
|
</div>
|
|
273
|
+
|
|
172
274
|
<div className={css.result}>
|
|
173
275
|
{error !== null ? <p className={css.error}>{error}</p> : null}
|
|
174
276
|
{outputs.length === 0 ? <p className={css.empty}>{tt('result.empty')}</p> : (
|
|
@@ -177,6 +279,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
177
279
|
<div className={css.audioList}>
|
|
178
280
|
{outputs.map((audio, index) => (
|
|
179
281
|
<div className={css.audioCard} key={audio.id}>
|
|
282
|
+
{audio.voiceId !== undefined ? <p className={css.hint}>新音色 ID:{audio.voiceId}</p> : null}
|
|
180
283
|
<audio className={css.audio} controls preload="metadata" src={dataUrlOf(audio)} />
|
|
181
284
|
<a className={css.download} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>下载</a>
|
|
182
285
|
</div>
|
|
@@ -185,6 +288,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
|
|
|
185
288
|
</>
|
|
186
289
|
)}
|
|
187
290
|
</div>
|
|
291
|
+
|
|
188
292
|
<aside className={css.history}>
|
|
189
293
|
<div className={css.historyHeader}>
|
|
190
294
|
<strong className={css.historyTitle}>{tt('history.title')}</strong>
|