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.
@@ -1,9 +1,17 @@
1
1
  /**
2
2
  * Host-side model/voice discovery.
3
3
  *
4
- * MiniMax exposes a voice-management API that returns all available system and
5
- * user-generated voice ids; we combine those with the known MiniMax music
6
- * models so the settings card can offer a full categorized catalog.
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
- const response = await fetch(url, {
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
- const base = baseUrl(channel.apiUrl).replace(/\/v1$/i, '')
52
- const url = `${base}/v1/get_voice`
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 + built-in music catalog' }
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
- // Best-effort OpenAI-compatible /models discovery.
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 response = await fetch(url, {
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
- const category = categoryFor(id) ?? 'tts'
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[] {
@@ -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
- hint: 'MiniMax 音色设计 / TTS / 音乐生成;可使用“获取可用模型”拉取账号音色',
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
- hint: 'Stability AI 音乐/音效生成(stable-audio 系列)',
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. */
@@ -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 { GENERATE_API, HISTORY_API, type AudioMode, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
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 visibleModels = useMemo(() => modelOptions.models
66
- .filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
67
- .map(entry => entry.alias), [modelOptions.models, mode])
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
- <label className={css.label}>
137
- <span>{tt('model.label')}</span>
138
- <select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
139
- {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
140
- {visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
141
- </select>
142
- </label>
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="alloy / 自定义音色" />
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
- <label className={css.label}>
150
- <span>{tt('speed.label')}</span>
151
- <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" />
152
- </label>
153
- <label className={css.label}>
154
- <span>{tt('duration.label')}</span>
155
- <input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
156
- </label>
157
- <label className={css.label}>
158
- <span>{tt('format.label')}</span>
159
- <select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
160
- <option value="mp3">mp3</option>
161
- <option value="wav">wav</option>
162
- <option value="flac">flac</option>
163
- <option value="ogg">ogg</option>
164
- <option value="pcm">pcm</option>
165
- </select>
166
- </label>
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>