dsh-audiogen 0.3.4 → 0.4.0
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/README.md +7 -1
- package/lib/client.js +2368 -630
- package/lib/client.js.map +1 -1
- package/lib/index.js +1022 -68
- package/package.json +1 -1
- package/skills/design/SKILL.md +7 -1
- package/skills/music/SKILL.md +37 -0
- package/skills/sfx/SKILL.md +17 -3
- package/src/agent-audio-tools.ts +159 -14
- package/src/audio-engine.ts +284 -18
- package/src/audio-presets.ts +10 -4
- package/src/audio-store.ts +251 -3
- package/src/client/AudioGenPanel.tsx +59 -334
- package/src/client/SettingsCard.tsx +9 -0
- package/src/client/api.ts +45 -2
- package/src/client/audio-panel.module.css +652 -79
- package/src/client/audio-player.tsx +87 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/library-save-dialog.tsx +173 -0
- package/src/client/library-view.tsx +511 -0
- package/src/client/library.module.css +686 -0
- package/src/client/locales.ts +6 -0
- package/src/client/settings-scope.ts +2 -0
- package/src/client/studio-view.tsx +560 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +130 -1
- package/src/routes.ts +247 -8
|
@@ -1,357 +1,82 @@
|
|
|
1
|
-
|
|
2
1
|
/**
|
|
3
|
-
* The AI 音频 panel:
|
|
4
|
-
*
|
|
5
|
-
* compatible models and shows its own parameters.
|
|
2
|
+
* The AI 音频 panel shell: header with 生成 / 资源库 tabs, toast host, and
|
|
3
|
+
* cross-tab hooks (library refresh after saves, «use this voice» reuse).
|
|
6
4
|
*/
|
|
7
5
|
|
|
8
|
-
import {
|
|
6
|
+
import { useRef, useState } from 'react'
|
|
9
7
|
import type { AudiogenApi } from './api.ts'
|
|
10
8
|
import type { AudiogenScope } from './settings-scope.ts'
|
|
11
|
-
import { audioModelOptions } from './settings-scope.ts'
|
|
12
9
|
import { tt } from './helpers.ts'
|
|
13
|
-
import {
|
|
10
|
+
import { GridIcon, ListIcon } from './icons.tsx'
|
|
11
|
+
import { StudioView, type StudioReuse } from './studio-view.tsx'
|
|
12
|
+
import { LibraryView } from './library-view.tsx'
|
|
14
13
|
import css from './audio-panel.module.css'
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
const [value, setValue] = useState(scope.getSnapshot().value)
|
|
18
|
-
useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
|
|
19
|
-
return value
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
|
|
23
|
-
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
|
24
|
-
const reload = (): void => {
|
|
25
|
-
void fetch(HISTORY_API.list, { method: 'POST' })
|
|
26
|
-
.then(async response => {
|
|
27
|
-
const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
|
|
28
|
-
if (body.ok === true) setEntries(body.history ?? [])
|
|
29
|
-
})
|
|
30
|
-
.catch(() => { /* history is best-effort */ })
|
|
31
|
-
}
|
|
32
|
-
useEffect(() => { reload() }, [])
|
|
33
|
-
const clear = (): void => {
|
|
34
|
-
void fetch(HISTORY_API.clear, { method: 'POST' })
|
|
35
|
-
.then(() => reload())
|
|
36
|
-
.catch(() => { /* best-effort */ })
|
|
37
|
-
}
|
|
38
|
-
return { entries, reload, clear }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function dataUrlOf(audio: GeneratedAudio): string {
|
|
42
|
-
return `data:${audio.mime};base64,${audio.b64}`
|
|
43
|
-
}
|
|
15
|
+
type Tab = 'studio' | 'library'
|
|
44
16
|
|
|
45
17
|
export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope }) {
|
|
46
18
|
const { api, scope } = props
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const [prompt, setPrompt] = useState('')
|
|
58
|
-
const [previewText, setPreviewText] = useState('')
|
|
59
|
-
const [model, setModel] = useState('')
|
|
60
|
-
const [voice, setVoice] = useState('')
|
|
61
|
-
const [speed, setSpeed] = useState('')
|
|
62
|
-
const [duration, setDuration] = useState('')
|
|
63
|
-
const [lyrics, setLyrics] = useState('')
|
|
64
|
-
const [instrumental, setInstrumental] = useState(false)
|
|
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)
|
|
75
|
-
const [loading, setLoading] = useState(false)
|
|
76
|
-
const [error, setError] = useState<string | null>(null)
|
|
77
|
-
const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
|
|
78
|
-
const { entries, reload, clear } = useHistory()
|
|
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
|
-
|
|
85
|
-
const visibleModels = useMemo(() => {
|
|
86
|
-
if (mode === 'voice_design') return []
|
|
87
|
-
return modelOptions.models
|
|
88
|
-
.filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
|
|
89
|
-
.map(entry => entry.alias)
|
|
90
|
-
}, [modelOptions.models, mode])
|
|
91
|
-
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
if (visibleModels.length > 0 && !visibleModels.includes(model)) {
|
|
94
|
-
setModel(visibleModels[0]!)
|
|
95
|
-
}
|
|
96
|
-
}, [visibleModels, model])
|
|
97
|
-
|
|
98
|
-
const submit = async (): Promise<void> => {
|
|
99
|
-
if (prompt.trim() === '') {
|
|
100
|
-
setError(tt('prompt.required'))
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
setLoading(true)
|
|
104
|
-
setError(null)
|
|
105
|
-
try {
|
|
106
|
-
const response = await api.generate({
|
|
107
|
-
mode,
|
|
108
|
-
model: (model || visibleModels[0]) ?? '',
|
|
109
|
-
prompt: prompt.trim(),
|
|
110
|
-
...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
|
|
111
|
-
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
112
|
-
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
113
|
-
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
114
|
-
...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
|
|
115
|
-
...(instrumental ? { isInstrumental: true } : {}),
|
|
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 } : {}),
|
|
125
|
-
})
|
|
126
|
-
if (!response.ok) {
|
|
127
|
-
setError(response.message ?? '生成失败')
|
|
128
|
-
return
|
|
129
|
-
}
|
|
130
|
-
setOutputs(response.outputs ?? [])
|
|
131
|
-
reload()
|
|
132
|
-
} catch (err) {
|
|
133
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
134
|
-
} finally {
|
|
135
|
-
setLoading(false)
|
|
136
|
-
}
|
|
19
|
+
const [tab, setTab] = useState<Tab>('studio')
|
|
20
|
+
const [libraryRev, setLibraryRev] = useState(0)
|
|
21
|
+
const [reuse, setReuse] = useState<StudioReuse | null>(null)
|
|
22
|
+
const [toast, setToast] = useState<string | null>(null)
|
|
23
|
+
const toastTimer = useRef<number | undefined>(undefined)
|
|
24
|
+
|
|
25
|
+
const showToast = (text: string): void => {
|
|
26
|
+
setToast(text)
|
|
27
|
+
if (toastTimer.current !== undefined) window.clearTimeout(toastTimer.current)
|
|
28
|
+
toastTimer.current = window.setTimeout(() => setToast(null), 2400)
|
|
137
29
|
}
|
|
138
30
|
|
|
139
|
-
const modeLabel = useMemo(() => {
|
|
140
|
-
if (mode === 'tts') return tt('mode.tts')
|
|
141
|
-
if (mode === 'music') return tt('mode.music')
|
|
142
|
-
if (mode === 'sfx') return tt('mode.sfx')
|
|
143
|
-
return tt('mode.voiceDesign')
|
|
144
|
-
}, [mode])
|
|
145
|
-
|
|
146
|
-
const needModel = mode !== 'voice_design'
|
|
147
|
-
|
|
148
31
|
return (
|
|
149
32
|
<div className={css.panel}>
|
|
150
33
|
<header className={css.header}>
|
|
151
34
|
<h2 className={css.title}>{tt('panel.title')}</h2>
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
<
|
|
172
|
-
<textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
|
|
173
|
-
</label>
|
|
174
|
-
|
|
175
|
-
{mode === 'voice_design' ? (
|
|
176
|
-
<label className={css.label}>
|
|
177
|
-
<span>试听文本</span>
|
|
178
|
-
<input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
|
|
179
|
-
</label>
|
|
180
|
-
) : null}
|
|
181
|
-
|
|
182
|
-
{needModel ? (
|
|
183
|
-
<label className={css.label}>
|
|
184
|
-
<span>{tt('model.label')}</span>
|
|
185
|
-
<select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
|
|
186
|
-
{visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
|
|
187
|
-
{visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
|
|
188
|
-
</select>
|
|
189
|
-
</label>
|
|
190
|
-
) : null}
|
|
191
|
-
|
|
192
|
-
{mode === 'tts' ? (
|
|
193
|
-
<label className={css.label}>
|
|
194
|
-
<span>{tt('voice.label')}</span>
|
|
195
|
-
<input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
|
|
196
|
-
</label>
|
|
197
|
-
) : null}
|
|
198
|
-
|
|
199
|
-
{mode === 'tts' ? (
|
|
200
|
-
<label className={css.label}>
|
|
201
|
-
<span>{tt('speed.label')}</span>
|
|
202
|
-
<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" />
|
|
203
|
-
</label>
|
|
204
|
-
) : null}
|
|
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
|
-
|
|
252
|
-
{mode === 'music' || mode === 'sfx' ? (
|
|
253
|
-
<label className={css.label}>
|
|
254
|
-
<span>{tt('duration.label')}</span>
|
|
255
|
-
<input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
|
|
256
|
-
</label>
|
|
257
|
-
) : null}
|
|
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
|
-
<div className={css.row}>
|
|
270
|
-
<label className={css.label}>
|
|
271
|
-
<span>采样率</span>
|
|
272
|
-
<select className={css.select} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
|
|
273
|
-
<option value="">默认(44100)</option>
|
|
274
|
-
<option value="16000">16000</option>
|
|
275
|
-
<option value="24000">24000</option>
|
|
276
|
-
<option value="32000">32000</option>
|
|
277
|
-
<option value="44100">44100</option>
|
|
278
|
-
</select>
|
|
279
|
-
</label>
|
|
280
|
-
<label className={css.label}>
|
|
281
|
-
<span>码率 bps</span>
|
|
282
|
-
<select className={css.select} value={bitrate} onChange={event => setBitrate(event.target.value)}>
|
|
283
|
-
<option value="">默认(256000)</option>
|
|
284
|
-
<option value="32000">32000</option>
|
|
285
|
-
<option value="64000">64000</option>
|
|
286
|
-
<option value="128000">128000</option>
|
|
287
|
-
<option value="256000">256000</option>
|
|
288
|
-
</select>
|
|
289
|
-
</label>
|
|
290
|
-
</div>
|
|
291
|
-
</>
|
|
292
|
-
) : null}
|
|
293
|
-
|
|
294
|
-
{needModel ? (
|
|
295
|
-
<label className={css.label}>
|
|
296
|
-
<span>{tt('format.label')}</span>
|
|
297
|
-
<select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
|
|
298
|
-
<option value="mp3">mp3</option>
|
|
299
|
-
<option value="wav">wav</option>
|
|
300
|
-
{mode === 'tts' ? (
|
|
301
|
-
<>
|
|
302
|
-
<option value="flac">flac</option>
|
|
303
|
-
<option value="ogg">ogg</option>
|
|
304
|
-
</>
|
|
305
|
-
) : null}
|
|
306
|
-
<option value="pcm">pcm</option>
|
|
307
|
-
</select>
|
|
308
|
-
</label>
|
|
309
|
-
) : null}
|
|
310
|
-
|
|
311
|
-
{!connected && <p className={css.hint}>{tt('config.missing')}</p>}
|
|
312
|
-
<button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
|
|
313
|
-
{loading ? tt('generating') : tt('generate')}
|
|
35
|
+
<div className={css.tabs} role="tablist" aria-label="AI 音频">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
role="tab"
|
|
39
|
+
aria-selected={tab === 'studio'}
|
|
40
|
+
className={css.tab}
|
|
41
|
+
data-active={tab === 'studio' ? 'true' : 'false'}
|
|
42
|
+
onClick={() => setTab('studio')}
|
|
43
|
+
>
|
|
44
|
+
<GridIcon /> {tt('tab.studio')}
|
|
45
|
+
</button>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
role="tab"
|
|
49
|
+
aria-selected={tab === 'library'}
|
|
50
|
+
className={css.tab}
|
|
51
|
+
data-active={tab === 'library' ? 'true' : 'false'}
|
|
52
|
+
onClick={() => setTab('library')}
|
|
53
|
+
>
|
|
54
|
+
<ListIcon /> {tt('tab.library')}
|
|
314
55
|
</button>
|
|
315
56
|
</div>
|
|
57
|
+
</header>
|
|
316
58
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
<button type="button" onClick={clear} style={{ border: 0, background: 'none', cursor: 'pointer', color: 'inherit', fontSize: 12 }}>清空</button>
|
|
339
|
-
</div>
|
|
340
|
-
{entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
|
|
341
|
-
<div>
|
|
342
|
-
{entries.map(entry => (
|
|
343
|
-
<div className={css.historyItem} key={entry.id}>
|
|
344
|
-
<div className={css.historyPrompt}>{entry.prompt}</div>
|
|
345
|
-
<div className={css.historyMeta}>{entry.mode} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
|
|
346
|
-
{entry.audio.map((audio, index) => (
|
|
347
|
-
<audio key={index} className={css.historyAudio} controls preload="none" src={audio.url} />
|
|
348
|
-
))}
|
|
349
|
-
</div>
|
|
350
|
-
))}
|
|
351
|
-
</div>
|
|
352
|
-
)}
|
|
353
|
-
</aside>
|
|
354
|
-
</div>
|
|
59
|
+
{tab === 'studio' ? (
|
|
60
|
+
<StudioView
|
|
61
|
+
api={api}
|
|
62
|
+
scope={scope}
|
|
63
|
+
reuse={reuse}
|
|
64
|
+
onLibraryChanged={() => setLibraryRev(revision => revision + 1)}
|
|
65
|
+
showToast={showToast}
|
|
66
|
+
/>
|
|
67
|
+
) : (
|
|
68
|
+
<LibraryView
|
|
69
|
+
api={api}
|
|
70
|
+
revision={libraryRev}
|
|
71
|
+
showToast={showToast}
|
|
72
|
+
onReuseVoice={payload => {
|
|
73
|
+
setReuse({ nonce: Date.now(), mode: payload.mode, ...(payload.voice === undefined ? {} : { voice: payload.voice }), ...(payload.voiceId === undefined ? {} : { voiceId: payload.voiceId }), ...(payload.model === undefined ? {} : { model: payload.model }) })
|
|
74
|
+
setTab('studio')
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{toast !== null ? <div className={css.toast} role="status">{toast}</div> : null}
|
|
355
80
|
</div>
|
|
356
81
|
)
|
|
357
82
|
}
|
|
@@ -27,6 +27,7 @@ export interface AudioGenSettings {
|
|
|
27
27
|
announceToAgent?: boolean
|
|
28
28
|
allowAgentAudioGeneration?: boolean
|
|
29
29
|
defaultModel?: string
|
|
30
|
+
autoSaveToLibrary?: boolean
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface AudioGenSettingsCardState extends CardShell {
|
|
@@ -35,6 +36,7 @@ export interface AudioGenSettingsCardState extends CardShell {
|
|
|
35
36
|
announceToAgent: CardFieldState
|
|
36
37
|
allowAgentAudioGeneration: CardFieldState
|
|
37
38
|
defaultModel: CardFieldState
|
|
39
|
+
autoSaveToLibrary: CardFieldState
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export interface AudioGenSettingsCardFace extends CardActions {
|
|
@@ -54,6 +56,7 @@ export class AudioGenSettingsCardController {
|
|
|
54
56
|
booleanField('announceToAgent'),
|
|
55
57
|
booleanField('allowAgentAudioGeneration'),
|
|
56
58
|
textField('defaultModel'),
|
|
59
|
+
booleanField('autoSaveToLibrary'),
|
|
57
60
|
])
|
|
58
61
|
this.channelsForm = new ChannelsForm(scope)
|
|
59
62
|
}
|
|
@@ -68,6 +71,7 @@ export class AudioGenSettingsCardController {
|
|
|
68
71
|
announceToAgent: this.form.field('announceToAgent'),
|
|
69
72
|
allowAgentAudioGeneration: this.form.field('allowAgentAudioGeneration'),
|
|
70
73
|
defaultModel: this.form.field('defaultModel'),
|
|
74
|
+
autoSaveToLibrary: this.form.field('autoSaveToLibrary'),
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -714,6 +718,11 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
|
|
|
714
718
|
<input type="checkbox" checked={state.allowAgentAudioGeneration.text === 'true' || state.allowAgentAudioGeneration.text === ''} disabled={!state.writable} onChange={event => props.edit('allowAgentAudioGeneration', String(event.target.checked))} /> {t('settings.allowAgentAudio')}
|
|
715
719
|
</label>
|
|
716
720
|
</div>
|
|
721
|
+
<div className={css.field}>
|
|
722
|
+
<label className={css.label}>
|
|
723
|
+
<input type="checkbox" checked={state.autoSaveToLibrary.text === 'true'} disabled={!state.writable} onChange={event => props.edit('autoSaveToLibrary', String(event.target.checked))} /> {t('settings.autoSaveLibrary')}
|
|
724
|
+
</label>
|
|
725
|
+
</div>
|
|
717
726
|
<div className={css.footer}>
|
|
718
727
|
{state.failed ? <p className={css.failed}>保存失败</p> : null}
|
|
719
728
|
<button type="button" className={css.discard} disabled={!state.dirty || state.saving} onClick={() => props.discard()}>{t('settings.discard')}</button>
|
package/src/client/api.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-side API client for the audio generation
|
|
2
|
+
* Browser-side API client for the audio generation, history and
|
|
3
|
+
* resource-library routes.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
GENERATE_API, HISTORY_API, LIBRARY_API,
|
|
8
|
+
type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry,
|
|
9
|
+
type LibraryEntry, type LibrarySaveRequest, type LibraryUpdateRequest,
|
|
10
|
+
} from '../protocol.ts'
|
|
6
11
|
|
|
7
12
|
export interface GenerateResponse {
|
|
8
13
|
ok: boolean
|
|
9
14
|
outputs?: GeneratedAudio[]
|
|
10
15
|
history?: HistoryEntry[]
|
|
11
16
|
historyError?: string
|
|
17
|
+
/** Resource-library entries created by the generation (auto-save). */
|
|
18
|
+
resources?: Array<{ id: string; name: string; type: string }>
|
|
12
19
|
code?: string
|
|
13
20
|
message?: string
|
|
14
21
|
}
|
|
@@ -33,4 +40,40 @@ export class AudiogenApi {
|
|
|
33
40
|
async clearHistory(): Promise<void> {
|
|
34
41
|
await fetch(HISTORY_API.clear, { method: 'POST' })
|
|
35
42
|
}
|
|
43
|
+
|
|
44
|
+
async libraryList(): Promise<LibraryEntry[]> {
|
|
45
|
+
const response = await fetch(LIBRARY_API.list, { method: 'POST' })
|
|
46
|
+
const body = await response.json() as { ok?: boolean; entries?: LibraryEntry[] }
|
|
47
|
+
return body.ok === true ? (body.entries ?? []) : []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async librarySave(request: LibrarySaveRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
|
|
51
|
+
const response = await fetch(LIBRARY_API.save, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/json' },
|
|
54
|
+
body: JSON.stringify(request),
|
|
55
|
+
})
|
|
56
|
+
const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
|
|
57
|
+
return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async libraryUpdate(request: LibraryUpdateRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
|
|
61
|
+
const response = await fetch(LIBRARY_API.update, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify(request),
|
|
65
|
+
})
|
|
66
|
+
const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
|
|
67
|
+
return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async libraryRemove(ids: string[]): Promise<{ ok: boolean }> {
|
|
71
|
+
const response = await fetch(LIBRARY_API.remove, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'content-type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ ids }),
|
|
75
|
+
})
|
|
76
|
+
const body = await response.json() as { ok?: boolean }
|
|
77
|
+
return { ok: body.ok === true }
|
|
78
|
+
}
|
|
36
79
|
}
|