dsh-audiogen 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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. */
@@ -60,12 +60,28 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
60
60
  const [voice, setVoice] = useState('')
61
61
  const [speed, setSpeed] = useState('')
62
62
  const [duration, setDuration] = useState('')
63
+ const [lyrics, setLyrics] = useState('')
64
+ const [instrumental, setInstrumental] = useState(false)
63
65
  const [format, setFormat] = useState('mp3')
66
+ // MiniMax TTS 高级参数(其他厂商忽略)
67
+ const [emotion, setEmotion] = useState('')
68
+ const [vol, setVol] = useState('')
69
+ const [pitch, setPitch] = useState('')
70
+ const [toneText, setToneText] = useState('')
71
+ const [sampleRate, setSampleRate] = useState('')
72
+ const [bitrate, setBitrate] = useState('')
73
+ const [audioChannel, setAudioChannel] = useState('')
74
+ const [subtitle, setSubtitle] = useState(false)
64
75
  const [loading, setLoading] = useState(false)
65
76
  const [error, setError] = useState<string | null>(null)
66
77
  const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
67
78
  const { entries, reload, clear } = useHistory()
68
79
 
80
+ const isMiniMaxChannel = useMemo(() => {
81
+ const target = channels.find(candidate => candidate.id === modelOptions.defaultChannelId) ?? channels[0]
82
+ return target !== undefined && (target.preset === 'minimax' || /minimax/i.test(target.apiUrl))
83
+ }, [channels, modelOptions.defaultChannelId])
84
+
69
85
  const visibleModels = useMemo(() => {
70
86
  if (mode === 'voice_design') return []
71
87
  return modelOptions.models
@@ -95,7 +111,17 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
95
111
  ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
96
112
  ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
97
113
  ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
114
+ ...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
115
+ ...(instrumental ? { isInstrumental: true } : {}),
98
116
  ...(format.trim() !== '' ? { format: format.trim() } : {}),
117
+ ...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
118
+ ...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
119
+ ...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
120
+ ...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
121
+ ...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
122
+ ...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
123
+ ...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
124
+ ...(subtitle ? { subtitleEnable: true } : {}),
99
125
  })
100
126
  if (!response.ok) {
101
127
  setError(response.message ?? '生成失败')
@@ -166,7 +192,7 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
166
192
  {mode === 'tts' ? (
167
193
  <label className={css.label}>
168
194
  <span>{tt('voice.label')}</span>
169
- <input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder="alloy / 自定义音色" />
195
+ <input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
170
196
  </label>
171
197
  ) : null}
172
198
 
@@ -177,6 +203,52 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
177
203
  </label>
178
204
  ) : null}
179
205
 
206
+ {mode === 'tts' && isMiniMaxChannel ? (
207
+ <details className={css.advanced}>
208
+ <summary>MiniMax 高级参数</summary>
209
+ <label className={css.label}>
210
+ <span>情绪 emotion</span>
211
+ <input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
212
+ </label>
213
+ <div className={css.row}>
214
+ <label className={css.label}>
215
+ <span>音量 vol (0-10)</span>
216
+ <input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
217
+ </label>
218
+ <label className={css.label}>
219
+ <span>音调 pitch (-12~12)</span>
220
+ <input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
221
+ </label>
222
+ </div>
223
+ <div className={css.row}>
224
+ <label className={css.label}>
225
+ <span>采样率</span>
226
+ <input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
227
+ </label>
228
+ <label className={css.label}>
229
+ <span>码率 bps</span>
230
+ <input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
231
+ </label>
232
+ <label className={css.label}>
233
+ <span>声道</span>
234
+ <select className={css.select} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
235
+ <option value="">默认(1)</option>
236
+ <option value="1">1</option>
237
+ <option value="2">2</option>
238
+ </select>
239
+ </label>
240
+ </div>
241
+ <label className={css.label}>
242
+ <span>发音词典(每行一条:"文字/读音")</span>
243
+ <textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
244
+ </label>
245
+ <label className={css.checkbox}>
246
+ <input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
247
+ <span>生成字幕 subtitle_enable</span>
248
+ </label>
249
+ </details>
250
+ ) : null}
251
+
180
252
  {mode === 'music' || mode === 'sfx' ? (
181
253
  <label className={css.label}>
182
254
  <span>{tt('duration.label')}</span>
@@ -184,14 +256,31 @@ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope })
184
256
  </label>
185
257
  ) : null}
186
258
 
259
+ {mode === 'music' ? (
260
+ <>
261
+ <label className={css.label}>
262
+ <span>歌词(纯音乐模式可留空;多段用空行分隔)</span>
263
+ <textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
264
+ </label>
265
+ <label className={css.checkbox}>
266
+ <input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
267
+ <span>纯音乐(无歌词/人声)is_instrumental</span>
268
+ </label>
269
+ </>
270
+ ) : null}
271
+
187
272
  {needModel ? (
188
273
  <label className={css.label}>
189
274
  <span>{tt('format.label')}</span>
190
275
  <select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
191
276
  <option value="mp3">mp3</option>
192
277
  <option value="wav">wav</option>
193
- <option value="flac">flac</option>
194
- <option value="ogg">ogg</option>
278
+ {mode === 'tts' ? (
279
+ <>
280
+ <option value="flac">flac</option>
281
+ <option value="ogg">ogg</option>
282
+ </>
283
+ ) : null}
195
284
  <option value="pcm">pcm</option>
196
285
  </select>
197
286
  </label>