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
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio view: the generation form (left), result cards (center) and the
|
|
3
|
+
* compact generation history (right). Owns the «加入资源库» interactions —
|
|
4
|
+
* a pre-generation checkbox, a per-card save dialog, and a history star.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
8
|
+
import type { AudiogenApi } from './api.ts'
|
|
9
|
+
import type { AudiogenConfig, AudiogenScope } from './settings-scope.ts'
|
|
10
|
+
import { audioModelOptions } from './settings-scope.ts'
|
|
11
|
+
import { tt } from './helpers.ts'
|
|
12
|
+
import {
|
|
13
|
+
HISTORY_API,
|
|
14
|
+
type AudioMode, type GeneratedAudio, type HistoryEntry, type LibraryEntry,
|
|
15
|
+
} from '../protocol.ts'
|
|
16
|
+
import { AudioPlayer } from './audio-player.tsx'
|
|
17
|
+
import { LibrarySaveDialog, type SaveDialogContext } from './library-save-dialog.tsx'
|
|
18
|
+
import { CheckIcon, DownloadIcon, StarIcon } from './icons.tsx'
|
|
19
|
+
import css from './audio-panel.module.css'
|
|
20
|
+
|
|
21
|
+
export interface StudioReuse {
|
|
22
|
+
nonce: number
|
|
23
|
+
mode: AudioMode
|
|
24
|
+
voice?: string
|
|
25
|
+
model?: string
|
|
26
|
+
voiceId?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function useConfig(scope: AudiogenScope) {
|
|
30
|
+
const [value, setValue] = useState(scope.getSnapshot().value)
|
|
31
|
+
useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
|
|
36
|
+
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
|
37
|
+
const reload = (): void => {
|
|
38
|
+
void fetch(HISTORY_API.list, { method: 'POST' })
|
|
39
|
+
.then(async response => {
|
|
40
|
+
const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
|
|
41
|
+
if (body.ok === true) setEntries(body.history ?? [])
|
|
42
|
+
})
|
|
43
|
+
.catch(() => { /* history is best-effort */ })
|
|
44
|
+
}
|
|
45
|
+
useEffect(() => { reload() }, [])
|
|
46
|
+
const clear = (): void => {
|
|
47
|
+
void fetch(HISTORY_API.clear, { method: 'POST' })
|
|
48
|
+
.then(() => reload())
|
|
49
|
+
.catch(() => { /* best-effort */ })
|
|
50
|
+
}
|
|
51
|
+
return { entries, reload, clear }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dataUrlOf(audio: GeneratedAudio): string {
|
|
55
|
+
return `data:${audio.mime};base64,${audio.b64}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fileNameOf(url: string): string {
|
|
59
|
+
try {
|
|
60
|
+
return decodeURIComponent(new URL(url, 'http://localhost').pathname.split('/').pop() ?? '')
|
|
61
|
+
} catch {
|
|
62
|
+
return ''
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Turn a history entry's audio refs into GeneratedAudio-shaped inputs. */
|
|
67
|
+
function audioRefsOfEntry(entry: HistoryEntry): GeneratedAudio[] {
|
|
68
|
+
return entry.audio.map(audio => {
|
|
69
|
+
const file = fileNameOf(audio.url)
|
|
70
|
+
return {
|
|
71
|
+
id: file.replace(/\.[a-z0-9]+$/i, ''),
|
|
72
|
+
file,
|
|
73
|
+
url: audio.url,
|
|
74
|
+
mime: audio.mime,
|
|
75
|
+
bytes: 0,
|
|
76
|
+
b64: '',
|
|
77
|
+
...(audio.duration === undefined ? {} : { duration: audio.duration }),
|
|
78
|
+
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contextOfEntry(entry: HistoryEntry): SaveDialogContext {
|
|
84
|
+
return {
|
|
85
|
+
mode: entry.mode,
|
|
86
|
+
prompt: entry.prompt,
|
|
87
|
+
...(entry.voice === undefined ? {} : { voice: entry.voice }),
|
|
88
|
+
...(entry.voiceId === undefined ? {} : { voiceId: entry.voiceId }),
|
|
89
|
+
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
90
|
+
...(entry.channel === undefined ? {} : { channel: entry.channel }),
|
|
91
|
+
...(entry.channelId === undefined ? {} : { channelId: entry.channelId }),
|
|
92
|
+
...(entry.params === undefined ? {} : { params: entry.params }),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface SaveDialogState {
|
|
97
|
+
files: GeneratedAudio[]
|
|
98
|
+
context: SaveDialogContext
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function StudioView(props: {
|
|
102
|
+
api: AudiogenApi
|
|
103
|
+
scope: AudiogenScope
|
|
104
|
+
config?: AudiogenConfig
|
|
105
|
+
reuse?: StudioReuse | null
|
|
106
|
+
onLibraryChanged: () => void
|
|
107
|
+
showToast: (text: string) => void
|
|
108
|
+
}): React.JSX.Element {
|
|
109
|
+
const { api, scope, config, reuse } = props
|
|
110
|
+
const effectiveConfig = useConfig(scope)
|
|
111
|
+
const cfg = (config ?? effectiveConfig)
|
|
112
|
+
const enabled = cfg?.enabled ?? true
|
|
113
|
+
const modelOptions = audioModelOptions(cfg)
|
|
114
|
+
const channels = cfg?.channels ?? []
|
|
115
|
+
const connected = enabled && channels.some(channel => {
|
|
116
|
+
const keyHeld = scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)
|
|
117
|
+
return channel.apiUrl.trim() !== '' && keyHeld && (channel.models.length > 0 || channel.preset === 'minimax')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const [mode, setMode] = useState<AudioMode>('tts')
|
|
121
|
+
const [prompt, setPrompt] = useState('')
|
|
122
|
+
const [previewText, setPreviewText] = useState('')
|
|
123
|
+
const [model, setModel] = useState('')
|
|
124
|
+
const [voice, setVoice] = useState('')
|
|
125
|
+
const [speed, setSpeed] = useState('')
|
|
126
|
+
const [duration, setDuration] = useState('')
|
|
127
|
+
const [lyrics, setLyrics] = useState('')
|
|
128
|
+
const [instrumental, setInstrumental] = useState(false)
|
|
129
|
+
// ElevenLabs 音效参数
|
|
130
|
+
const [loop, setLoop] = useState(false)
|
|
131
|
+
const [promptInfluence, setPromptInfluence] = useState('')
|
|
132
|
+
const [format, setFormat] = useState('mp3')
|
|
133
|
+
// MiniMax TTS 高级参数(其他厂商忽略)
|
|
134
|
+
const [emotion, setEmotion] = useState('')
|
|
135
|
+
const [vol, setVol] = useState('')
|
|
136
|
+
const [pitch, setPitch] = useState('')
|
|
137
|
+
const [toneText, setToneText] = useState('')
|
|
138
|
+
const [sampleRate, setSampleRate] = useState('')
|
|
139
|
+
const [bitrate, setBitrate] = useState('')
|
|
140
|
+
const [audioChannel, setAudioChannel] = useState('')
|
|
141
|
+
const [subtitle, setSubtitle] = useState(false)
|
|
142
|
+
const [loading, setLoading] = useState(false)
|
|
143
|
+
const [error, setError] = useState<string | null>(null)
|
|
144
|
+
const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
|
|
145
|
+
// 资源库
|
|
146
|
+
const [saveToLibrary, setSaveToLibrary] = useState(cfg?.autoSaveToLibrary === true)
|
|
147
|
+
const [savedIds, setSavedIds] = useState<Set<string>>(new Set())
|
|
148
|
+
const [saveDialog, setSaveDialog] = useState<SaveDialogState | null>(null)
|
|
149
|
+
const { entries, reload, clear } = useHistory()
|
|
150
|
+
// 音色设计模式的厂商/渠道选择(默认渠道)
|
|
151
|
+
const [designChannelId, setDesignChannelId] = useState('')
|
|
152
|
+
|
|
153
|
+
const isMiniMaxChannel = useMemo(() => {
|
|
154
|
+
const target = channels.find(candidate => candidate.id === modelOptions.defaultChannelId) ?? channels[0]
|
|
155
|
+
return target !== undefined && (target.preset === 'minimax' || /minimax/i.test(target.apiUrl))
|
|
156
|
+
}, [channels, modelOptions.defaultChannelId])
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (channels.length === 0) return
|
|
160
|
+
if (designChannelId === '' || !channels.some(candidate => candidate.id === designChannelId)) {
|
|
161
|
+
setDesignChannelId(modelOptions.defaultChannelId ?? channels[0]!.id)
|
|
162
|
+
}
|
|
163
|
+
}, [channels, modelOptions.defaultChannelId, designChannelId])
|
|
164
|
+
|
|
165
|
+
const visibleModels = useMemo(() => {
|
|
166
|
+
if (mode === 'voice_design') return []
|
|
167
|
+
return modelOptions.models
|
|
168
|
+
.filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
|
|
169
|
+
.map(entry => entry.alias)
|
|
170
|
+
}, [modelOptions.models, mode])
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (visibleModels.length > 0 && !visibleModels.includes(model)) {
|
|
174
|
+
setModel(visibleModels[0]!)
|
|
175
|
+
}
|
|
176
|
+
}, [visibleModels, model])
|
|
177
|
+
|
|
178
|
+
// 资源库「用此音色」回填
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (reuse === undefined || reuse === null) return
|
|
181
|
+
setMode(reuse.mode)
|
|
182
|
+
if (reuse.voiceId !== undefined || reuse.voice !== undefined) setVoice(reuse.voiceId ?? reuse.voice ?? '')
|
|
183
|
+
if (reuse.model !== undefined && reuse.model !== '') setModel(reuse.model)
|
|
184
|
+
}, [reuse?.nonce])
|
|
185
|
+
|
|
186
|
+
const submit = async (): Promise<void> => {
|
|
187
|
+
if (prompt.trim() === '') {
|
|
188
|
+
setError(tt('prompt.required'))
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
setLoading(true)
|
|
192
|
+
setError(null)
|
|
193
|
+
try {
|
|
194
|
+
const response = await api.generate({
|
|
195
|
+
mode,
|
|
196
|
+
model: mode === 'voice_design' ? '' : (model || visibleModels[0]) ?? '',
|
|
197
|
+
prompt: prompt.trim(),
|
|
198
|
+
saveToLibrary,
|
|
199
|
+
...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
|
|
200
|
+
...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
|
|
201
|
+
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
202
|
+
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
203
|
+
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
204
|
+
...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
|
|
205
|
+
...(instrumental ? { isInstrumental: true } : {}),
|
|
206
|
+
...(loop ? { loop: true } : {}),
|
|
207
|
+
...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
|
|
208
|
+
...(format.trim() !== '' ? { format: format.trim() } : {}),
|
|
209
|
+
...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
|
|
210
|
+
...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
|
|
211
|
+
...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
|
|
212
|
+
...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
|
|
213
|
+
...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
|
|
214
|
+
...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
|
|
215
|
+
...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
|
|
216
|
+
...(subtitle ? { subtitleEnable: true } : {}),
|
|
217
|
+
})
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
setError(response.message ?? '生成失败')
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const generated = response.outputs ?? []
|
|
223
|
+
setOutputs(generated)
|
|
224
|
+
if ((response.resources?.length ?? 0) > 0 && saveToLibrary) {
|
|
225
|
+
setSavedIds(new Set(generated.map(item => item.id)))
|
|
226
|
+
props.showToast('已保存到资源库')
|
|
227
|
+
props.onLibraryChanged()
|
|
228
|
+
}
|
|
229
|
+
reload()
|
|
230
|
+
} catch (err) {
|
|
231
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
232
|
+
} finally {
|
|
233
|
+
setLoading(false)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const openSaveDialog = (files: GeneratedAudio[], context: SaveDialogContext): void => {
|
|
238
|
+
setSaveDialog({ files, context })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const onDialogSaved = (entry: LibraryEntry): void => {
|
|
242
|
+
if (saveDialog !== null) {
|
|
243
|
+
setSavedIds(current => new Set([...current, ...saveDialog.files.map(file => file.id)]))
|
|
244
|
+
}
|
|
245
|
+
setSaveDialog(null)
|
|
246
|
+
props.showToast(`已保存「${entry.name}」`)
|
|
247
|
+
props.onLibraryChanged()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const modeLabel = useMemo(() => {
|
|
251
|
+
if (mode === 'tts') return tt('mode.tts')
|
|
252
|
+
if (mode === 'music') return tt('mode.music')
|
|
253
|
+
if (mode === 'sfx') return tt('mode.sfx')
|
|
254
|
+
return tt('mode.voiceDesign')
|
|
255
|
+
}, [mode])
|
|
256
|
+
|
|
257
|
+
const needModel = mode !== 'voice_design'
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div className={css.studio}>
|
|
261
|
+
<div className={css.formCol}>
|
|
262
|
+
<div className={css.modeRow}>
|
|
263
|
+
{(['tts', 'music', 'sfx', 'voice_design'] as const).map(item => (
|
|
264
|
+
<button
|
|
265
|
+
key={item}
|
|
266
|
+
type="button"
|
|
267
|
+
className={css.modeButton}
|
|
268
|
+
data-active={mode === item ? 'true' : 'false'}
|
|
269
|
+
onClick={() => setMode(item)}
|
|
270
|
+
>
|
|
271
|
+
{item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : item === 'sfx' ? tt('mode.sfx') : tt('mode.voiceDesign')}
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<label className={css.label}>
|
|
277
|
+
<span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
|
|
278
|
+
<textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
|
|
279
|
+
</label>
|
|
280
|
+
|
|
281
|
+
{mode === 'voice_design' ? (
|
|
282
|
+
<>
|
|
283
|
+
<label className={css.label}>
|
|
284
|
+
<span>厂商 / 渠道</span>
|
|
285
|
+
<select className={css.input} value={designChannelId} onChange={event => setDesignChannelId(event.target.value)}>
|
|
286
|
+
{channels.length === 0 ? <option value="">(尚未配置渠道)</option> : null}
|
|
287
|
+
{channels.map(candidate => (
|
|
288
|
+
<option key={candidate.id} value={candidate.id}>
|
|
289
|
+
{candidate.name}({candidate.preset === 'minimax' ? 'MiniMax' : candidate.preset === 'elevenlabs' ? 'ElevenLabs' : candidate.preset || '自定义'})
|
|
290
|
+
</option>
|
|
291
|
+
))}
|
|
292
|
+
</select>
|
|
293
|
+
</label>
|
|
294
|
+
<p className={css.hint}>MiniMax:/v1/voice_design;ElevenLabs:/v1/text-to-voice/design(试听文本 100-1000 字符,过短将自动生成)</p>
|
|
295
|
+
<label className={css.label}>
|
|
296
|
+
<span>试听文本</span>
|
|
297
|
+
<input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
|
|
298
|
+
</label>
|
|
299
|
+
</>
|
|
300
|
+
) : null}
|
|
301
|
+
|
|
302
|
+
{needModel ? (
|
|
303
|
+
<label className={css.label}>
|
|
304
|
+
<span>{tt('model.label')}</span>
|
|
305
|
+
<select className={css.input} value={model} onChange={event => setModel(event.target.value)}>
|
|
306
|
+
{visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
|
|
307
|
+
{visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
|
|
308
|
+
</select>
|
|
309
|
+
</label>
|
|
310
|
+
) : null}
|
|
311
|
+
|
|
312
|
+
{mode === 'tts' ? (
|
|
313
|
+
<label className={css.label}>
|
|
314
|
+
<span>{tt('voice.label')}</span>
|
|
315
|
+
<input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
|
|
316
|
+
</label>
|
|
317
|
+
) : null}
|
|
318
|
+
|
|
319
|
+
{mode === 'tts' ? (
|
|
320
|
+
<label className={css.label}>
|
|
321
|
+
<span>{tt('speed.label')}</span>
|
|
322
|
+
<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" />
|
|
323
|
+
</label>
|
|
324
|
+
) : null}
|
|
325
|
+
|
|
326
|
+
{mode === 'tts' && isMiniMaxChannel ? (
|
|
327
|
+
<details className={css.advanced}>
|
|
328
|
+
<summary>MiniMax 高级参数</summary>
|
|
329
|
+
<label className={css.label}>
|
|
330
|
+
<span>情绪 emotion</span>
|
|
331
|
+
<input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
|
|
332
|
+
</label>
|
|
333
|
+
<div className={css.row}>
|
|
334
|
+
<label className={css.label}>
|
|
335
|
+
<span>音量 vol (0-10)</span>
|
|
336
|
+
<input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
|
|
337
|
+
</label>
|
|
338
|
+
<label className={css.label}>
|
|
339
|
+
<span>音调 pitch (-12~12)</span>
|
|
340
|
+
<input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
|
|
341
|
+
</label>
|
|
342
|
+
</div>
|
|
343
|
+
<div className={css.row}>
|
|
344
|
+
<label className={css.label}>
|
|
345
|
+
<span>采样率</span>
|
|
346
|
+
<input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
|
|
347
|
+
</label>
|
|
348
|
+
<label className={css.label}>
|
|
349
|
+
<span>码率 bps</span>
|
|
350
|
+
<input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
|
|
351
|
+
</label>
|
|
352
|
+
<label className={css.label}>
|
|
353
|
+
<span>声道</span>
|
|
354
|
+
<select className={css.input} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
|
|
355
|
+
<option value="">默认(1)</option>
|
|
356
|
+
<option value="1">1</option>
|
|
357
|
+
<option value="2">2</option>
|
|
358
|
+
</select>
|
|
359
|
+
</label>
|
|
360
|
+
</div>
|
|
361
|
+
<label className={css.label}>
|
|
362
|
+
<span>发音词典(每行一条:"文字/读音")</span>
|
|
363
|
+
<textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
|
|
364
|
+
</label>
|
|
365
|
+
<label className={css.checkbox}>
|
|
366
|
+
<input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
|
|
367
|
+
<span>生成字幕 subtitle_enable</span>
|
|
368
|
+
</label>
|
|
369
|
+
</details>
|
|
370
|
+
) : null}
|
|
371
|
+
|
|
372
|
+
{mode === 'music' || mode === 'sfx' ? (
|
|
373
|
+
<label className={css.label}>
|
|
374
|
+
<span>{tt('duration.label')}</span>
|
|
375
|
+
<input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
|
|
376
|
+
</label>
|
|
377
|
+
) : null}
|
|
378
|
+
|
|
379
|
+
{mode === 'sfx' ? (
|
|
380
|
+
<>
|
|
381
|
+
<label className={css.checkbox}>
|
|
382
|
+
<input type="checkbox" checked={loop} onChange={event => setLoop(event.target.checked)} />
|
|
383
|
+
<span>循环音效 loop(无缝循环,需 eleven_text_to_sound_v2)</span>
|
|
384
|
+
</label>
|
|
385
|
+
<label className={css.label}>
|
|
386
|
+
<span>提示词影响度 prompt_influence (0-1)</span>
|
|
387
|
+
<input className={css.input} type="number" step="0.1" min="0" max="1" value={promptInfluence} onChange={event => setPromptInfluence(event.target.value)} placeholder="0.3" />
|
|
388
|
+
</label>
|
|
389
|
+
</>
|
|
390
|
+
) : null}
|
|
391
|
+
|
|
392
|
+
{mode === 'music' ? (
|
|
393
|
+
<>
|
|
394
|
+
<label className={css.label}>
|
|
395
|
+
<span>歌词(纯音乐模式可留空;多段用空行分隔)</span>
|
|
396
|
+
<textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
|
|
397
|
+
</label>
|
|
398
|
+
<label className={css.checkbox}>
|
|
399
|
+
<input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
|
|
400
|
+
<span>纯音乐(无歌词/人声)is_instrumental</span>
|
|
401
|
+
</label>
|
|
402
|
+
<div className={css.row}>
|
|
403
|
+
<label className={css.label}>
|
|
404
|
+
<span>采样率</span>
|
|
405
|
+
<select className={css.input} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
|
|
406
|
+
<option value="">默认(44100)</option>
|
|
407
|
+
<option value="16000">16000</option>
|
|
408
|
+
<option value="24000">24000</option>
|
|
409
|
+
<option value="32000">32000</option>
|
|
410
|
+
<option value="44100">44100</option>
|
|
411
|
+
</select>
|
|
412
|
+
</label>
|
|
413
|
+
<label className={css.label}>
|
|
414
|
+
<span>码率 bps</span>
|
|
415
|
+
<select className={css.input} value={bitrate} onChange={event => setBitrate(event.target.value)}>
|
|
416
|
+
<option value="">默认(256000)</option>
|
|
417
|
+
<option value="32000">32000</option>
|
|
418
|
+
<option value="64000">64000</option>
|
|
419
|
+
<option value="128000">128000</option>
|
|
420
|
+
<option value="256000">256000</option>
|
|
421
|
+
</select>
|
|
422
|
+
</label>
|
|
423
|
+
</div>
|
|
424
|
+
</>
|
|
425
|
+
) : null}
|
|
426
|
+
|
|
427
|
+
{needModel ? (
|
|
428
|
+
<label className={css.label}>
|
|
429
|
+
<span>{tt('format.label')}</span>
|
|
430
|
+
<select className={css.input} value={format} onChange={event => setFormat(event.target.value)}>
|
|
431
|
+
<option value="mp3">mp3</option>
|
|
432
|
+
<option value="wav">wav</option>
|
|
433
|
+
{mode === 'tts' ? (
|
|
434
|
+
<>
|
|
435
|
+
<option value="flac">flac</option>
|
|
436
|
+
<option value="ogg">ogg</option>
|
|
437
|
+
</>
|
|
438
|
+
) : null}
|
|
439
|
+
<option value="pcm">pcm</option>
|
|
440
|
+
</select>
|
|
441
|
+
</label>
|
|
442
|
+
) : null}
|
|
443
|
+
|
|
444
|
+
<label className={css.checkbox} title="生成完成后自动保存到资源库;也可在设置中开启全部自动保存">
|
|
445
|
+
<input type="checkbox" checked={saveToLibrary} onChange={event => setSaveToLibrary(event.target.checked)} />
|
|
446
|
+
<span>生成后保存到资源库</span>
|
|
447
|
+
</label>
|
|
448
|
+
|
|
449
|
+
{!connected && <p className={css.hint}>{tt('config.missing')}</p>}
|
|
450
|
+
<button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
|
|
451
|
+
{loading ? tt('generating') : tt('generate')}
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
<div className={css.resultCol}>
|
|
456
|
+
{error !== null ? <p className={css.error}>{error}</p> : null}
|
|
457
|
+
{outputs.length === 0 ? (
|
|
458
|
+
<div className={css.resultEmpty}>
|
|
459
|
+
<span className={css.resultEmptyIcon}>🎵</span>
|
|
460
|
+
<p>{tt('result.empty')}</p>
|
|
461
|
+
<p className={css.resultEmptyHint}>生成结果将在这里播放、下载,并可一键加入资源库</p>
|
|
462
|
+
</div>
|
|
463
|
+
) : (
|
|
464
|
+
<>
|
|
465
|
+
<div className={css.resultMeta}>
|
|
466
|
+
<span>{tt('result.done', { count: outputs.length })}</span>
|
|
467
|
+
<span className={css.resultModeChip}>{modeLabel}</span>
|
|
468
|
+
</div>
|
|
469
|
+
<div className={css.audioList}>
|
|
470
|
+
{outputs.map((audio, index) => {
|
|
471
|
+
const saved = savedIds.has(audio.id)
|
|
472
|
+
return (
|
|
473
|
+
<div className={css.audioCard} key={audio.id} data-saved={saved ? 'true' : 'false'}>
|
|
474
|
+
<div className={css.audioCardHead}>
|
|
475
|
+
{audio.voiceId !== undefined ? <span className={css.voiceIdChip} title="新音色 ID">新音色 {audio.voiceId}</span> : null}
|
|
476
|
+
{saved ? (
|
|
477
|
+
<span className={css.savedChip}><CheckIcon /> 已入库</span>
|
|
478
|
+
) : null}
|
|
479
|
+
<span className={css.audioCardIndex}>#{index + 1}</span>
|
|
480
|
+
</div>
|
|
481
|
+
<AudioPlayer src={dataUrlOf(audio)} itemKey={audio.id} />
|
|
482
|
+
<div className={css.audioCardActions}>
|
|
483
|
+
<a className={css.ghostButton} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>
|
|
484
|
+
<DownloadIcon /> 下载
|
|
485
|
+
</a>
|
|
486
|
+
{saved ? (
|
|
487
|
+
<button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
|
|
488
|
+
<CheckIcon /> 已入库
|
|
489
|
+
</button>
|
|
490
|
+
) : (
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
className={css.ghostButton}
|
|
494
|
+
onClick={() => openSaveDialog([audio], {
|
|
495
|
+
mode,
|
|
496
|
+
prompt: prompt.trim(),
|
|
497
|
+
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
498
|
+
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
499
|
+
...(model !== '' ? { model } : {}),
|
|
500
|
+
...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
|
|
501
|
+
params: {
|
|
502
|
+
mode,
|
|
503
|
+
model: mode === 'voice_design' ? '' : (model || visibleModels[0]) ?? '',
|
|
504
|
+
prompt: prompt.trim(),
|
|
505
|
+
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
506
|
+
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
507
|
+
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
508
|
+
...(format.trim() !== '' ? { format: format.trim() } : {}),
|
|
509
|
+
},
|
|
510
|
+
})}
|
|
511
|
+
>
|
|
512
|
+
<StarIcon /> 加入资源库
|
|
513
|
+
</button>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
)
|
|
518
|
+
})}
|
|
519
|
+
</div>
|
|
520
|
+
</>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
<aside className={css.historyCol}>
|
|
525
|
+
<div className={css.historyHeader}>
|
|
526
|
+
<strong className={css.historyTitle}>{tt('history.title')}</strong>
|
|
527
|
+
<button type="button" className={css.historyClear} onClick={clear}>清空</button>
|
|
528
|
+
</div>
|
|
529
|
+
{entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
|
|
530
|
+
<div className={css.historyList}>
|
|
531
|
+
{entries.map(entry => (
|
|
532
|
+
<div className={css.historyItem} key={entry.id}>
|
|
533
|
+
<div className={css.historyPrompt}>{entry.prompt}</div>
|
|
534
|
+
<div className={css.historyMeta}>{entry.mode} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
|
|
535
|
+
{entry.audio.map((audio, index) => (
|
|
536
|
+
<AudioPlayer key={index} src={audio.url} compact itemKey={`${entry.id}-${index}`} />
|
|
537
|
+
))}
|
|
538
|
+
<div className={css.historyActions}>
|
|
539
|
+
<button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(entry), contextOfEntry(entry))}>
|
|
540
|
+
<StarIcon /> 入库
|
|
541
|
+
</button>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
))}
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
</aside>
|
|
548
|
+
|
|
549
|
+
{saveDialog !== null ? (
|
|
550
|
+
<LibrarySaveDialog
|
|
551
|
+
api={api}
|
|
552
|
+
files={saveDialog.files}
|
|
553
|
+
context={saveDialog.context}
|
|
554
|
+
onClose={() => setSaveDialog(null)}
|
|
555
|
+
onSaved={onDialogSaved}
|
|
556
|
+
/>
|
|
557
|
+
) : null}
|
|
558
|
+
</div>
|
|
559
|
+
)
|
|
560
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface Config {
|
|
|
36
36
|
channelSecrets?: Record<string, string>
|
|
37
37
|
defaultChannelId?: string
|
|
38
38
|
defaultModel?: string
|
|
39
|
+
autoSaveToLibrary?: boolean
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export const Config: z<Config> = z.object({
|
|
@@ -55,6 +56,7 @@ export const Config: z<Config> = z.object({
|
|
|
55
56
|
channelSecrets: z.dict(z.string().role('secret')).default({}),
|
|
56
57
|
defaultChannelId: z.string().default(''),
|
|
57
58
|
defaultModel: z.string().default(''),
|
|
59
|
+
autoSaveToLibrary: z.boolean().default(false),
|
|
58
60
|
})
|
|
59
61
|
|
|
60
62
|
const DEFAULT_ENABLED = true
|
|
@@ -116,6 +118,7 @@ export interface EffectiveConfig {
|
|
|
116
118
|
channels: AudioChannel[]
|
|
117
119
|
defaultChannelId: string
|
|
118
120
|
defaultModel: string
|
|
121
|
+
autoSaveToLibrary: boolean
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
export function apply(ctx: Context, config?: Config): void {
|
|
@@ -142,6 +145,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
142
145
|
})),
|
|
143
146
|
defaultChannelId,
|
|
144
147
|
defaultModel: typeof value.defaultModel === 'string' ? value.defaultModel.trim() : '',
|
|
148
|
+
autoSaveToLibrary: value.autoSaveToLibrary === true,
|
|
145
149
|
}
|
|
146
150
|
}
|
|
147
151
|
|
|
@@ -156,6 +160,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
156
160
|
const routes = makeRoutes({
|
|
157
161
|
settings: seam,
|
|
158
162
|
resolveChannels: channelsView,
|
|
163
|
+
autoSave: () => resolve().autoSaveToLibrary,
|
|
159
164
|
})
|
|
160
165
|
const disposers = routes.map(route => ctx.webServer.register(route))
|
|
161
166
|
return () => { for (const dispose of disposers) dispose() }
|
|
@@ -170,6 +175,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
170
175
|
allowAgentAudioGeneration: value.allowAgentAudioGeneration,
|
|
171
176
|
channels: value.channels,
|
|
172
177
|
defaultChannelId: value.defaultChannelId,
|
|
178
|
+
autoSaveToLibrary: value.autoSaveToLibrary,
|
|
173
179
|
}
|
|
174
180
|
}), 'dsh-audiogen: agent audio tools')
|
|
175
181
|
})
|