dsh-audiogen 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/lib/client.js +1700 -760
- package/lib/client.js.map +1 -1
- package/lib/index.js +412 -162
- package/package.json +1 -1
- package/skills/design/SKILL.md +5 -0
- package/skills/music/SKILL.md +5 -0
- package/skills/sfx/SKILL.md +5 -0
- package/skills/tts/SKILL.md +5 -0
- package/src/agent-audio-tools.ts +265 -149
- package/src/audio-scheduler.ts +73 -0
- package/src/client/SettingsCard.tsx +18 -0
- package/src/client/api.ts +24 -25
- package/src/client/audio-panel.module.css +328 -0
- package/src/client/field-specs.ts +186 -0
- package/src/client/library-view.tsx +6 -1
- package/src/client/locales.ts +2 -0
- package/src/client/settings-scope.ts +18 -3
- package/src/client/studio-view.tsx +772 -254
- package/src/index.ts +46 -0
- package/src/protocol.ts +10 -1
- package/src/routes.ts +50 -2
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Studio view: the generation form (left), result cards (center) and the
|
|
3
3
|
* compact generation history (right). Owns the «加入资源库» interactions —
|
|
4
|
-
* a pre-generation checkbox, a per-card save dialog, and a history star
|
|
4
|
+
* a pre-generation checkbox, a per-card save dialog, and a history star —
|
|
5
|
+
* plus a model-comparison mode that runs the same prompt across several
|
|
6
|
+
* models and shows one result group per model.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
9
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
8
10
|
import type { AudiogenApi } from './api.ts'
|
|
9
11
|
import type { AudiogenConfig, AudiogenScope } from './settings-scope.ts'
|
|
10
12
|
import { audioModelOptions } from './settings-scope.ts'
|
|
13
|
+
import { globalFieldSpecs, overrideRowSpecs, presetLabel, type FieldSpec } from './field-specs.ts'
|
|
11
14
|
import { tt } from './helpers.ts'
|
|
12
15
|
import {
|
|
13
16
|
HISTORY_API,
|
|
14
|
-
type AudioMode, type GeneratedAudio, type HistoryEntry, type LibraryEntry,
|
|
17
|
+
type AudioMode, type GeneratedAudio, type GenerateAudioRequest, type HistoryEntry, type LibraryEntry,
|
|
15
18
|
} from '../protocol.ts'
|
|
16
19
|
import { AudioPlayer } from './audio-player.tsx'
|
|
17
20
|
import { LibrarySaveDialog, type SaveDialogContext } from './library-save-dialog.tsx'
|
|
@@ -26,6 +29,78 @@ export interface StudioReuse {
|
|
|
26
29
|
voiceId?: string
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/** One row of a model-comparison run. */
|
|
33
|
+
interface CompareResult {
|
|
34
|
+
model: string
|
|
35
|
+
state: 'waiting' | 'running' | 'done' | 'error' | 'cancelled'
|
|
36
|
+
outputs: GeneratedAudio[]
|
|
37
|
+
error?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 一个生成任务(单模型或模型对比):提交即建任务,非阻塞;可取消、可并行。 */
|
|
41
|
+
interface StudioTask {
|
|
42
|
+
id: string
|
|
43
|
+
mode: AudioMode
|
|
44
|
+
kind: 'single' | 'compare'
|
|
45
|
+
prompt?: string
|
|
46
|
+
label: string
|
|
47
|
+
status: 'running' | 'done' | 'failed' | 'cancelled'
|
|
48
|
+
progress: { done: number; total: number; current: string }
|
|
49
|
+
startedAt: number
|
|
50
|
+
finishedAt?: number
|
|
51
|
+
groups: CompareResult[]
|
|
52
|
+
error?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 每模型覆盖值 → 请求字段的数值/类型转换(空值跳过)。 */
|
|
56
|
+
function overrideSpread(override: Record<string, string>): Partial<GenerateAudioRequest> {
|
|
57
|
+
const out: Partial<GenerateAudioRequest> = {}
|
|
58
|
+
const val = (override.format ?? '').trim()
|
|
59
|
+
if (val !== '') out.format = val
|
|
60
|
+
const num = (key: string): number | undefined => {
|
|
61
|
+
const raw = (override[key] ?? '').trim()
|
|
62
|
+
if (raw === '') return undefined
|
|
63
|
+
const parsed = Number(raw)
|
|
64
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
65
|
+
}
|
|
66
|
+
const duration = num('duration')
|
|
67
|
+
if (duration !== undefined) out.duration = duration
|
|
68
|
+
const voice = (override.voice ?? '').trim()
|
|
69
|
+
if (voice !== '') out.voice = voice
|
|
70
|
+
const speed = num('speed')
|
|
71
|
+
if (speed !== undefined) out.speed = speed
|
|
72
|
+
const emotion = (override.emotion ?? '').trim()
|
|
73
|
+
if (emotion !== '') out.emotion = emotion
|
|
74
|
+
const sampleRate = num('sample_rate')
|
|
75
|
+
if (sampleRate !== undefined) out.sampleRate = sampleRate
|
|
76
|
+
const bitrate = num('bitrate')
|
|
77
|
+
if (bitrate !== undefined) out.bitrate = bitrate
|
|
78
|
+
const lyrics = (override.lyrics ?? '').trim()
|
|
79
|
+
if (lyrics !== '') out.lyrics = lyrics
|
|
80
|
+
const seed = num('seed')
|
|
81
|
+
if (seed !== undefined) out.seed = seed
|
|
82
|
+
const steps = num('steps')
|
|
83
|
+
if (steps !== undefined) out.steps = steps
|
|
84
|
+
const cfgScale = num('cfg_scale')
|
|
85
|
+
if (cfgScale !== undefined) out.cfgScale = cfgScale
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 历史记录分组 tab(全部 + 按模式)。 */
|
|
90
|
+
type HistoryTab = 'all' | AudioMode
|
|
91
|
+
|
|
92
|
+
function taskIdOf(entry: HistoryEntry): string {
|
|
93
|
+
const params = entry.params as Record<string, unknown> | undefined
|
|
94
|
+
return typeof params?.taskId === 'string' && params.taskId !== '' ? params.taskId : ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function modeLabelOf(mode: AudioMode): string {
|
|
98
|
+
if (mode === 'tts') return tt('mode.tts')
|
|
99
|
+
if (mode === 'music') return tt('mode.music')
|
|
100
|
+
if (mode === 'sfx') return tt('mode.sfx')
|
|
101
|
+
return tt('mode.voiceDesign')
|
|
102
|
+
}
|
|
103
|
+
|
|
29
104
|
function useConfig(scope: AudiogenScope) {
|
|
30
105
|
const [value, setValue] = useState(scope.getSnapshot().value)
|
|
31
106
|
useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
|
|
@@ -35,7 +110,11 @@ function useConfig(scope: AudiogenScope) {
|
|
|
35
110
|
function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
|
|
36
111
|
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
|
37
112
|
const reload = (): void => {
|
|
38
|
-
void fetch(HISTORY_API.list, {
|
|
113
|
+
void fetch(HISTORY_API.list, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'content-type': 'application/json' },
|
|
116
|
+
body: '{}',
|
|
117
|
+
})
|
|
39
118
|
.then(async response => {
|
|
40
119
|
const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
|
|
41
120
|
if (body.ok === true) setEntries(body.history ?? [])
|
|
@@ -44,7 +123,11 @@ function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: ()
|
|
|
44
123
|
}
|
|
45
124
|
useEffect(() => { reload() }, [])
|
|
46
125
|
const clear = (): void => {
|
|
47
|
-
void fetch(HISTORY_API.clear, {
|
|
126
|
+
void fetch(HISTORY_API.clear, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
body: '{}',
|
|
130
|
+
})
|
|
48
131
|
.then(() => reload())
|
|
49
132
|
.catch(() => { /* best-effort */ })
|
|
50
133
|
}
|
|
@@ -139,13 +222,27 @@ export function StudioView(props: {
|
|
|
139
222
|
const [bitrate, setBitrate] = useState('')
|
|
140
223
|
const [audioChannel, setAudioChannel] = useState('')
|
|
141
224
|
const [subtitle, setSubtitle] = useState(false)
|
|
142
|
-
|
|
225
|
+
// Stable Audio 参数(仅 Stability 渠道显示)
|
|
226
|
+
const [seed, setSeed] = useState('')
|
|
227
|
+
const [steps, setSteps] = useState('')
|
|
228
|
+
const [cfgScale, setCfgScale] = useState('')
|
|
143
229
|
const [error, setError] = useState<string | null>(null)
|
|
144
|
-
|
|
230
|
+
// 任务列表:提交即建任务,非阻塞;可同时存在多个进行中的任务。
|
|
231
|
+
const [tasks, setTasks] = useState<StudioTask[]>([])
|
|
232
|
+
const tasksRef = useRef<StudioTask[]>([])
|
|
233
|
+
useEffect(() => { tasksRef.current = tasks }, [tasks])
|
|
234
|
+
const taskControllers = useRef(new Map<string, AbortController[]>())
|
|
145
235
|
// 资源库
|
|
146
236
|
const [saveToLibrary, setSaveToLibrary] = useState(cfg?.autoSaveToLibrary === true)
|
|
147
237
|
const [savedIds, setSavedIds] = useState<Set<string>>(new Set())
|
|
148
238
|
const [saveDialog, setSaveDialog] = useState<SaveDialogState | null>(null)
|
|
239
|
+
// 历史记录分类 tab
|
|
240
|
+
const [historyTab, setHistoryTab] = useState<HistoryTab>('all')
|
|
241
|
+
// 模型对比
|
|
242
|
+
const [compareMode, setCompareMode] = useState(false)
|
|
243
|
+
const [compareModels, setCompareModels] = useState<string[]>([])
|
|
244
|
+
// 每模型参数覆盖(留空 = 自动沿用全局)
|
|
245
|
+
const [overrides, setOverrides] = useState<Record<string, Record<string, string>>>({})
|
|
149
246
|
const { entries, reload, clear } = useHistory()
|
|
150
247
|
// 音色设计模式的厂商/渠道选择(默认渠道)
|
|
151
248
|
const [designChannelId, setDesignChannelId] = useState('')
|
|
@@ -169,12 +266,66 @@ export function StudioView(props: {
|
|
|
169
266
|
.map(entry => entry.alias)
|
|
170
267
|
}, [modelOptions.models, mode])
|
|
171
268
|
|
|
269
|
+
// 当前模型所属渠道 preset(单模型模式);对比模式为所选模型渠道集合。
|
|
270
|
+
const currentPreset = useMemo(() => {
|
|
271
|
+
if (mode === 'voice_design') return channels.find(candidate => candidate.id === designChannelId)?.preset ?? ''
|
|
272
|
+
const picked = modelOptions.models.find(entry => entry.alias === model) ?? modelOptions.models.find(entry => entry.alias === (visibleModels[0] ?? ''))
|
|
273
|
+
return picked?.preset ?? ''
|
|
274
|
+
}, [mode, model, visibleModels, modelOptions.models, channels, designChannelId])
|
|
275
|
+
|
|
276
|
+
const fieldPresets = useMemo(() => {
|
|
277
|
+
if (mode === 'voice_design') return []
|
|
278
|
+
if (compareMode) {
|
|
279
|
+
const presets: string[] = []
|
|
280
|
+
for (const alias of compareModels) {
|
|
281
|
+
const entry = modelOptions.models.find(candidate => candidate.alias === alias)
|
|
282
|
+
if (entry !== undefined && !presets.includes(entry.preset)) presets.push(entry.preset)
|
|
283
|
+
}
|
|
284
|
+
if (presets.length === 0) return [currentPreset].filter(value => value !== '')
|
|
285
|
+
return presets
|
|
286
|
+
}
|
|
287
|
+
return [currentPreset].filter(value => value !== '')
|
|
288
|
+
}, [mode, compareMode, compareModels, modelOptions.models, currentPreset])
|
|
289
|
+
|
|
290
|
+
// 按(模式 × 渠道集合)计算全局字段:对比模式只留共有字段,独有字段在覆盖矩阵中。
|
|
291
|
+
const globalSpecs = useMemo(() => globalFieldSpecs(mode, fieldPresets), [mode, fieldPresets])
|
|
292
|
+
|
|
293
|
+
// 模型下拉按渠道分组。
|
|
294
|
+
const groupedModels = useMemo(() => {
|
|
295
|
+
const groups: Array<{ channelId: string; channelName: string; models: Array<{ alias: string }> }> = []
|
|
296
|
+
for (const entry of modelOptions.models) {
|
|
297
|
+
let group = groups.find(candidate => candidate.channelId === entry.channelId)
|
|
298
|
+
if (group === undefined) {
|
|
299
|
+
group = { channelId: entry.channelId, channelName: entry.channelName, models: [] }
|
|
300
|
+
groups.push(group)
|
|
301
|
+
}
|
|
302
|
+
group.models.push({ alias: entry.alias })
|
|
303
|
+
}
|
|
304
|
+
return groups.map(group => ({ ...group, models: group.models.filter(entry => visibleModels.includes(entry.alias)) })).filter(group => group.models.length > 0)
|
|
305
|
+
}, [modelOptions.models, visibleModels])
|
|
306
|
+
|
|
172
307
|
useEffect(() => {
|
|
173
308
|
if (visibleModels.length > 0 && !visibleModels.includes(model)) {
|
|
174
309
|
setModel(visibleModels[0]!)
|
|
175
310
|
}
|
|
176
311
|
}, [visibleModels, model])
|
|
177
312
|
|
|
313
|
+
// 切模式时清空对比结果(参数含义变了)
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
setCompareModels(current => current.filter(item => visibleModels.includes(item)))
|
|
316
|
+
}, [mode, visibleModels])
|
|
317
|
+
|
|
318
|
+
// 进入对比模式时确保至少选中 2 个有效模型
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!compareMode) return
|
|
321
|
+
setCompareModels(current => {
|
|
322
|
+
const valid = current.filter(item => visibleModels.includes(item))
|
|
323
|
+
const rest = visibleModels.filter(item => !valid.includes(item))
|
|
324
|
+
while (valid.length < 2 && rest.length > 0) valid.push(rest.shift()!)
|
|
325
|
+
return valid
|
|
326
|
+
})
|
|
327
|
+
}, [compareMode, visibleModels])
|
|
328
|
+
|
|
178
329
|
// 资源库「用此音色」回填
|
|
179
330
|
useEffect(() => {
|
|
180
331
|
if (reuse === undefined || reuse === null) return
|
|
@@ -183,61 +334,321 @@ export function StudioView(props: {
|
|
|
183
334
|
if (reuse.model !== undefined && reuse.model !== '') setModel(reuse.model)
|
|
184
335
|
}, [reuse?.nonce])
|
|
185
336
|
|
|
186
|
-
|
|
337
|
+
/** Build the shared generation request for one model. */
|
|
338
|
+
const requestOf = (modelName: string): GenerateAudioRequest => ({
|
|
339
|
+
mode,
|
|
340
|
+
model: modelName,
|
|
341
|
+
prompt: prompt.trim(),
|
|
342
|
+
saveToLibrary,
|
|
343
|
+
...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
|
|
344
|
+
...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
|
|
345
|
+
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
346
|
+
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
347
|
+
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
348
|
+
...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
|
|
349
|
+
...(instrumental ? { isInstrumental: true } : {}),
|
|
350
|
+
...(loop ? { loop: true } : {}),
|
|
351
|
+
...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
|
|
352
|
+
...(format.trim() !== '' ? { format: format.trim() } : {}),
|
|
353
|
+
...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
|
|
354
|
+
...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
|
|
355
|
+
...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
|
|
356
|
+
...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
|
|
357
|
+
...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
|
|
358
|
+
...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
|
|
359
|
+
...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
|
|
360
|
+
...(subtitle ? { subtitleEnable: true } : {}),
|
|
361
|
+
...(seed.trim() !== '' ? { seed: Number(seed) } : {}),
|
|
362
|
+
...(steps.trim() !== '' ? { steps: Number(steps) } : {}),
|
|
363
|
+
...(cfgScale.trim() !== '' ? { cfgScale: Number(cfgScale) } : {}),
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const applyResponse = (response: Awaited<ReturnType<AudiogenApi['generate']>>): GeneratedAudio[] => {
|
|
367
|
+
const generated = response.outputs ?? []
|
|
368
|
+
if ((response.resources?.length ?? 0) > 0 && saveToLibrary) {
|
|
369
|
+
setSavedIds(current => new Set([...current, ...generated.map(item => item.id)]))
|
|
370
|
+
props.showToast('已保存到资源库')
|
|
371
|
+
props.onLibraryChanged()
|
|
372
|
+
}
|
|
373
|
+
reload()
|
|
374
|
+
return generated
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const patchTask = (taskId: string, fn: (task: StudioTask) => StudioTask): void => {
|
|
378
|
+
setTasks(current => current.map(task => task.id === taskId ? fn(task) : task))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 提交即建任务:非阻塞,可继续发起其他生成;并发由宿主「最大并发生成数」闸门控制。 */
|
|
382
|
+
const submit = (): void => {
|
|
187
383
|
if (prompt.trim() === '') {
|
|
188
384
|
setError(tt('prompt.required'))
|
|
189
385
|
return
|
|
190
386
|
}
|
|
191
|
-
|
|
387
|
+
const isCompare = compareMode && needModel
|
|
388
|
+
const models = isCompare ? (compareModels.length >= 2 ? compareModels : visibleModels.slice(0, 2)) : []
|
|
389
|
+
if (isCompare && models.length < 2) {
|
|
390
|
+
setError('请至少选择 2 个模型进行对比')
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
const singleModel = isCompare ? '' : (model || visibleModels[0] || '')
|
|
394
|
+
if (!isCompare && singleModel === '') {
|
|
395
|
+
setError('当前模式暂无可用模型')
|
|
396
|
+
return
|
|
397
|
+
}
|
|
192
398
|
setError(null)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
399
|
+
const taskId = `t-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
400
|
+
const planModels = isCompare ? models : [singleModel]
|
|
401
|
+
// 参数快照:任务启动后表单再变化不影响本次生成;每模型再叠加各自覆盖。
|
|
402
|
+
const plan = planModels.map(modelName => ({
|
|
403
|
+
model: modelName,
|
|
404
|
+
request: { ...requestOf(modelName), taskId, ...overrideSpread(overrides[modelName] ?? {}) },
|
|
405
|
+
}))
|
|
406
|
+
const task: StudioTask = {
|
|
407
|
+
id: taskId,
|
|
408
|
+
mode,
|
|
409
|
+
prompt: prompt.trim(),
|
|
410
|
+
kind: isCompare ? 'compare' : 'single',
|
|
411
|
+
label: isCompare ? `对比 ${models.join(' / ')}` : singleModel,
|
|
412
|
+
status: 'running',
|
|
413
|
+
progress: { done: 0, total: plan.length, current: '' },
|
|
414
|
+
startedAt: Date.now(),
|
|
415
|
+
groups: planModels.map(modelName => ({ model: modelName, state: 'waiting' as const, outputs: [] as GeneratedAudio[] })),
|
|
416
|
+
}
|
|
417
|
+
setTasks(current => [task, ...current])
|
|
418
|
+
void runTask(taskId, plan)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** 执行一个任务:并行发起(宿主闸门限流),进度回写;支持取消。 */
|
|
422
|
+
const runTask = async (taskId: string, plan: Array<{ model: string; request: GenerateAudioRequest }>): Promise<void> => {
|
|
423
|
+
const controllers: AbortController[] = []
|
|
424
|
+
taskControllers.current.set(taskId, controllers)
|
|
425
|
+
const taskIsFinished = (): 'running' | 'cancelled' | 'pending' => {
|
|
426
|
+
const task = tasksRef.current.find(candidate => candidate.id === taskId)
|
|
427
|
+
if (task === undefined) return 'pending'
|
|
428
|
+
if (task.status === 'cancelled') return 'cancelled'
|
|
429
|
+
return 'running'
|
|
430
|
+
}
|
|
431
|
+
await Promise.allSettled(plan.map(async (step) => {
|
|
432
|
+
const controller = new AbortController()
|
|
433
|
+
controllers.push(controller)
|
|
434
|
+
patchTask(taskId, task => ({
|
|
435
|
+
...task,
|
|
436
|
+
progress: { ...task.progress, current: step.model },
|
|
437
|
+
groups: task.groups.map(group => group.model === step.model ? { ...group, state: 'running' as const, error: undefined } : group),
|
|
438
|
+
}))
|
|
439
|
+
try {
|
|
440
|
+
const response = await api.generate(step.request, controller.signal)
|
|
441
|
+
if (!response.ok) throw new Error(response.message ?? '生成失败')
|
|
442
|
+
const generated = applyResponse(response)
|
|
443
|
+
patchTask(taskId, task => ({
|
|
444
|
+
...task,
|
|
445
|
+
groups: task.groups.map(group => group.model === step.model ? { ...group, state: 'done' as const, outputs: generated } : group),
|
|
446
|
+
}))
|
|
447
|
+
} catch (err) {
|
|
448
|
+
if (controller.signal.aborted === true || taskIsFinished() === 'cancelled') {
|
|
449
|
+
patchTask(taskId, task => ({
|
|
450
|
+
...task,
|
|
451
|
+
groups: task.groups.map(group => group.model === step.model ? { ...group, state: 'cancelled' as const, error: undefined } : group),
|
|
452
|
+
}))
|
|
453
|
+
} else {
|
|
454
|
+
patchTask(taskId, task => ({
|
|
455
|
+
...task,
|
|
456
|
+
groups: task.groups.map(group => group.model === step.model
|
|
457
|
+
? { ...group, state: 'error' as const, error: err instanceof Error ? err.message : String(err) }
|
|
458
|
+
: group),
|
|
459
|
+
}))
|
|
460
|
+
}
|
|
461
|
+
} finally {
|
|
462
|
+
patchTask(taskId, task => ({ ...task, progress: { ...task.progress, done: task.progress.done + 1, current: '' } }))
|
|
221
463
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
464
|
+
}))
|
|
465
|
+
taskControllers.current.delete(taskId)
|
|
466
|
+
setTasks(current => current.map(task => {
|
|
467
|
+
if (task.id !== taskId) return task
|
|
468
|
+
if (task.status === 'cancelled') return { ...task, finishedAt: task.finishedAt ?? Date.now() }
|
|
469
|
+
const done = task.groups.filter(group => group.state === 'done').length
|
|
470
|
+
const failed = task.groups.filter(group => group.state === 'error').length
|
|
471
|
+
const cancelled = task.groups.filter(group => group.state === 'cancelled').length
|
|
472
|
+
if (done > 0) return { ...task, status: 'done', finishedAt: Date.now() }
|
|
473
|
+
if (cancelled === task.groups.length) return { ...task, status: 'cancelled', finishedAt: Date.now() }
|
|
474
|
+
return {
|
|
475
|
+
...task,
|
|
476
|
+
status: 'failed',
|
|
477
|
+
finishedAt: Date.now(),
|
|
478
|
+
error: failed > 0 ? task.groups.filter(group => group.state === 'error').map(group => `「${group.model}」${group.error ?? ''}`).join(';') : '生成失败',
|
|
228
479
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
480
|
+
}))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 取消任务:本地中止在途 fetch + 宿主中断上游请求,剩余模型跳过。 */
|
|
484
|
+
const cancelTask = (taskId: string): void => {
|
|
485
|
+
for (const controller of taskControllers.current.get(taskId) ?? []) controller.abort()
|
|
486
|
+
void api.cancelTask(taskId)
|
|
487
|
+
patchTask(taskId, task => ({
|
|
488
|
+
...task,
|
|
489
|
+
status: 'cancelled',
|
|
490
|
+
finishedAt: Date.now(),
|
|
491
|
+
progress: { ...task.progress, current: '' },
|
|
492
|
+
groups: task.groups.map(group => group.state === 'waiting' || group.state === 'running'
|
|
493
|
+
? { ...group, state: 'cancelled' as const, error: undefined }
|
|
494
|
+
: group),
|
|
495
|
+
}))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const removeTask = (taskId: string): void => {
|
|
499
|
+
setTasks(current => current.filter(task => task.id !== taskId))
|
|
235
500
|
}
|
|
236
501
|
|
|
237
502
|
const openSaveDialog = (files: GeneratedAudio[], context: SaveDialogContext): void => {
|
|
238
503
|
setSaveDialog({ files, context })
|
|
239
504
|
}
|
|
240
505
|
|
|
506
|
+
/** 按字段规格渲染一个表单控件(渠道/模式感知:字段集由 globalSpecs 决定)。 */
|
|
507
|
+
const renderField = (spec: FieldSpec): React.JSX.Element => {
|
|
508
|
+
const common = { className: css.input, disabled: false }
|
|
509
|
+
switch (spec.key) {
|
|
510
|
+
case 'voice':
|
|
511
|
+
return (
|
|
512
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
513
|
+
<span>{spec.label}</span>
|
|
514
|
+
<input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={currentPreset === 'minimax' ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
|
|
515
|
+
</label>
|
|
516
|
+
)
|
|
517
|
+
case 'speed':
|
|
518
|
+
return (
|
|
519
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
520
|
+
<span>{spec.label}</span>
|
|
521
|
+
<input className={css.input} type="number" step={spec.step ?? 0.1} min={spec.min} max={spec.max} value={speed} onChange={event => setSpeed(event.target.value)} placeholder={spec.placeholder} />
|
|
522
|
+
</label>
|
|
523
|
+
)
|
|
524
|
+
case 'duration':
|
|
525
|
+
return (
|
|
526
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
527
|
+
<span>{spec.label}</span>
|
|
528
|
+
<input className={css.input} type="number" step={spec.step ?? 1} min={spec.min} max={spec.max} value={duration} onChange={event => setDuration(event.target.value)} placeholder={spec.placeholder} />
|
|
529
|
+
</label>
|
|
530
|
+
)
|
|
531
|
+
case 'format':
|
|
532
|
+
return (
|
|
533
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
534
|
+
<span>{spec.label}</span>
|
|
535
|
+
<select className={css.input} value={format} onChange={event => setFormat(event.target.value)}>
|
|
536
|
+
{(spec.options ?? ['mp3', 'wav', 'pcm']).map(option => <option key={option} value={option}>{option}</option>)}
|
|
537
|
+
</select>
|
|
538
|
+
</label>
|
|
539
|
+
)
|
|
540
|
+
case 'lyrics':
|
|
541
|
+
return (
|
|
542
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
543
|
+
<span>{spec.label}</span>
|
|
544
|
+
<textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
|
|
545
|
+
</label>
|
|
546
|
+
)
|
|
547
|
+
case 'instrumental':
|
|
548
|
+
return (
|
|
549
|
+
<label className={css.checkbox} key={spec.key} title={spec.hint}>
|
|
550
|
+
<input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
|
|
551
|
+
<span>{spec.label}(是:{currentPreset === 'elevenlabs' ? 'force_instrumental' : 'is_instrumental'})</span>
|
|
552
|
+
</label>
|
|
553
|
+
)
|
|
554
|
+
case 'sampleRate':
|
|
555
|
+
return (
|
|
556
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
557
|
+
<span>{spec.label}</span>
|
|
558
|
+
<select className={css.input} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
|
|
559
|
+
<option value="">默认(44100)</option>
|
|
560
|
+
{(spec.options ?? []).map(option => <option key={option} value={option}>{option}</option>)}
|
|
561
|
+
</select>
|
|
562
|
+
</label>
|
|
563
|
+
)
|
|
564
|
+
case 'bitrate':
|
|
565
|
+
return (
|
|
566
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
567
|
+
<span>{spec.label}</span>
|
|
568
|
+
<select className={css.input} value={bitrate} onChange={event => setBitrate(event.target.value)}>
|
|
569
|
+
<option value="">默认(256000)</option>
|
|
570
|
+
{(spec.options ?? []).map(option => <option key={option} value={option}>{option}</option>)}
|
|
571
|
+
</select>
|
|
572
|
+
</label>
|
|
573
|
+
)
|
|
574
|
+
case 'audioChannel':
|
|
575
|
+
return (
|
|
576
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
577
|
+
<span>{spec.label}</span>
|
|
578
|
+
<select className={css.input} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
|
|
579
|
+
<option value="">默认(1)</option>
|
|
580
|
+
<option value="1">1</option>
|
|
581
|
+
<option value="2">2</option>
|
|
582
|
+
</select>
|
|
583
|
+
</label>
|
|
584
|
+
)
|
|
585
|
+
case 'emotion':
|
|
586
|
+
return (
|
|
587
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
588
|
+
<span>{spec.label}</span>
|
|
589
|
+
<input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder={spec.placeholder} />
|
|
590
|
+
</label>
|
|
591
|
+
)
|
|
592
|
+
case 'vol':
|
|
593
|
+
return (
|
|
594
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
595
|
+
<span>{spec.label}</span>
|
|
596
|
+
<input className={css.input} type="number" min={spec.min} max={spec.max} step={spec.step ?? 0.5} value={vol} onChange={event => setVol(event.target.value)} placeholder={spec.placeholder} />
|
|
597
|
+
</label>
|
|
598
|
+
)
|
|
599
|
+
case 'pitch':
|
|
600
|
+
return (
|
|
601
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
602
|
+
<span>{spec.label}</span>
|
|
603
|
+
<input className={css.input} type="number" min={spec.min} max={spec.max} value={pitch} onChange={event => setPitch(event.target.value)} placeholder={spec.placeholder} />
|
|
604
|
+
</label>
|
|
605
|
+
)
|
|
606
|
+
case 'toneText':
|
|
607
|
+
return (
|
|
608
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
609
|
+
<span>{spec.label}</span>
|
|
610
|
+
<textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={spec.placeholder} />
|
|
611
|
+
</label>
|
|
612
|
+
)
|
|
613
|
+
case 'subtitle':
|
|
614
|
+
return (
|
|
615
|
+
<label className={css.checkbox} key={spec.key} title={spec.hint}>
|
|
616
|
+
<input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
|
|
617
|
+
<span>{spec.label}</span>
|
|
618
|
+
</label>
|
|
619
|
+
)
|
|
620
|
+
case 'loop':
|
|
621
|
+
return (
|
|
622
|
+
<label className={css.checkbox} key={spec.key} title={spec.hint}>
|
|
623
|
+
<input type="checkbox" checked={loop} onChange={event => setLoop(event.target.checked)} />
|
|
624
|
+
<span>{spec.label}</span>
|
|
625
|
+
</label>
|
|
626
|
+
)
|
|
627
|
+
case 'promptInfluence':
|
|
628
|
+
return (
|
|
629
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
630
|
+
<span>{spec.label}</span>
|
|
631
|
+
<input className={css.input} type="number" step={spec.step ?? 0.1} min={spec.min} max={spec.max} value={promptInfluence} onChange={event => setPromptInfluence(event.target.value)} placeholder={spec.placeholder} />
|
|
632
|
+
</label>
|
|
633
|
+
)
|
|
634
|
+
case 'seed':
|
|
635
|
+
case 'steps':
|
|
636
|
+
case 'cfgScale': {
|
|
637
|
+
const value = spec.key === 'seed' ? seed : spec.key === 'steps' ? steps : cfgScale
|
|
638
|
+
const setter = spec.key === 'seed' ? setSeed : spec.key === 'steps' ? setSteps : setCfgScale
|
|
639
|
+
// 覆盖矩阵中的数字输入用统一 state(cfgScale 入参键 cfg_scale)。
|
|
640
|
+
return (
|
|
641
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
642
|
+
<span>{spec.label}</span>
|
|
643
|
+
<input {...common} type="number" step={spec.step ?? 1} min={spec.min} max={spec.max} value={String(value)} placeholder={spec.placeholder} onChange={event => setter(event.target.value)} />
|
|
644
|
+
</label>
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
default:
|
|
648
|
+
return <span key={spec.key}>{spec.label}</span>
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
241
652
|
const onDialogSaved = (entry: LibraryEntry): void => {
|
|
242
653
|
if (saveDialog !== null) {
|
|
243
654
|
setSavedIds(current => new Set([...current, ...saveDialog.files.map(file => file.id)]))
|
|
@@ -247,6 +658,49 @@ export function StudioView(props: {
|
|
|
247
658
|
props.onLibraryChanged()
|
|
248
659
|
}
|
|
249
660
|
|
|
661
|
+
/** One result card (single mode shares it with the compare groups). */
|
|
662
|
+
const renderAudioCard = (audio: GeneratedAudio, index: number, label: string, contextModel: string): React.JSX.Element => {
|
|
663
|
+
const saved = savedIds.has(audio.id)
|
|
664
|
+
return (
|
|
665
|
+
<div className={css.audioCard} key={`${label}-${audio.id}`} data-saved={saved ? 'true' : 'false'}>
|
|
666
|
+
<div className={css.audioCardHead}>
|
|
667
|
+
{audio.voiceId !== undefined ? <span className={css.voiceIdChip} title="新音色 ID">新音色 {audio.voiceId}</span> : null}
|
|
668
|
+
{saved ? (
|
|
669
|
+
<span className={css.savedChip}><CheckIcon /> 已入库</span>
|
|
670
|
+
) : null}
|
|
671
|
+
<span className={css.audioCardIndex}>#{index + 1}</span>
|
|
672
|
+
</div>
|
|
673
|
+
<AudioPlayer src={dataUrlOf(audio)} itemKey={`${label}-${audio.id}`} />
|
|
674
|
+
<div className={css.audioCardActions}>
|
|
675
|
+
<a className={css.ghostButton} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>
|
|
676
|
+
<DownloadIcon /> 下载
|
|
677
|
+
</a>
|
|
678
|
+
{saved ? (
|
|
679
|
+
<button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
|
|
680
|
+
<CheckIcon /> 已入库
|
|
681
|
+
</button>
|
|
682
|
+
) : (
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
className={css.ghostButton}
|
|
686
|
+
onClick={() => openSaveDialog([audio], {
|
|
687
|
+
mode,
|
|
688
|
+
prompt: prompt.trim(),
|
|
689
|
+
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
690
|
+
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
691
|
+
...(contextModel !== '' ? { model: contextModel } : {}),
|
|
692
|
+
...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
|
|
693
|
+
params: requestOf(contextModel) as unknown as Record<string, unknown>,
|
|
694
|
+
})}
|
|
695
|
+
>
|
|
696
|
+
<StarIcon /> 加入资源库
|
|
697
|
+
</button>
|
|
698
|
+
)}
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
250
704
|
const modeLabel = useMemo(() => {
|
|
251
705
|
if (mode === 'tts') return tt('mode.tts')
|
|
252
706
|
if (mode === 'music') return tt('mode.music')
|
|
@@ -254,7 +708,60 @@ export function StudioView(props: {
|
|
|
254
708
|
return tt('mode.voiceDesign')
|
|
255
709
|
}, [mode])
|
|
256
710
|
|
|
711
|
+
/** 历史记录:按 taskId 聚合出「单条 / 对比任务卡」两种条目。 */
|
|
712
|
+
const historyItems = useMemo((): Array<{
|
|
713
|
+
key: string
|
|
714
|
+
kind: 'single' | 'compare'
|
|
715
|
+
mode: AudioMode
|
|
716
|
+
prompt: string
|
|
717
|
+
createdAt: number
|
|
718
|
+
entry: HistoryEntry
|
|
719
|
+
models: Array<{ model: string; channel?: string; entry: HistoryEntry }>
|
|
720
|
+
}> => {
|
|
721
|
+
const taskCounts = new Map<string, number>()
|
|
722
|
+
for (const entry of entries) {
|
|
723
|
+
const taskId = taskIdOf(entry)
|
|
724
|
+
if (taskId !== '') taskCounts.set(taskId, (taskCounts.get(taskId) ?? 0) + 1)
|
|
725
|
+
}
|
|
726
|
+
const merged: Array<{
|
|
727
|
+
key: string
|
|
728
|
+
kind: 'single' | 'compare'
|
|
729
|
+
mode: AudioMode
|
|
730
|
+
prompt: string
|
|
731
|
+
createdAt: number
|
|
732
|
+
entry: HistoryEntry
|
|
733
|
+
models: Array<{ model: string; channel?: string; entry: HistoryEntry }>
|
|
734
|
+
}> = []
|
|
735
|
+
const byTask = new Map<string, typeof merged[number]>()
|
|
736
|
+
for (const entry of entries) {
|
|
737
|
+
const taskId = taskIdOf(entry)
|
|
738
|
+
if (taskId !== '' && (taskCounts.get(taskId) ?? 0) > 1) {
|
|
739
|
+
const existing = byTask.get(taskId)
|
|
740
|
+
if (existing !== undefined) {
|
|
741
|
+
existing.models.push({ model: entry.model, ...(entry.channel === undefined ? {} : { channel: entry.channel }), entry })
|
|
742
|
+
if (entry.createdAt > existing.createdAt) existing.createdAt = entry.createdAt
|
|
743
|
+
continue
|
|
744
|
+
}
|
|
745
|
+
const item: typeof merged[number] = {
|
|
746
|
+
key: taskId,
|
|
747
|
+
kind: 'compare',
|
|
748
|
+
mode: entry.mode,
|
|
749
|
+
prompt: entry.prompt,
|
|
750
|
+
createdAt: entry.createdAt,
|
|
751
|
+
entry,
|
|
752
|
+
models: [{ model: entry.model, ...(entry.channel === undefined ? {} : { channel: entry.channel }), entry }],
|
|
753
|
+
}
|
|
754
|
+
byTask.set(taskId, item)
|
|
755
|
+
merged.push(item)
|
|
756
|
+
continue
|
|
757
|
+
}
|
|
758
|
+
merged.push({ key: entry.id, kind: 'single', mode: entry.mode, prompt: entry.prompt, createdAt: entry.createdAt, entry, models: [] })
|
|
759
|
+
}
|
|
760
|
+
return merged.sort((left, right) => right.createdAt - left.createdAt)
|
|
761
|
+
}, [entries])
|
|
762
|
+
|
|
257
763
|
const needModel = mode !== 'voice_design'
|
|
764
|
+
const runningCount = tasks.filter(task => task.status === 'running').length
|
|
258
765
|
|
|
259
766
|
return (
|
|
260
767
|
<div className={css.studio}>
|
|
@@ -300,145 +807,108 @@ export function StudioView(props: {
|
|
|
300
807
|
) : null}
|
|
301
808
|
|
|
302
809
|
{needModel ? (
|
|
303
|
-
<label className={css.
|
|
304
|
-
<
|
|
305
|
-
<
|
|
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 / 自定义音色'} />
|
|
810
|
+
<label className={css.checkbox} title="选择多个模型,用相同参数逐个生成,便于对比效果">
|
|
811
|
+
<input type="checkbox" checked={compareMode} onChange={event => setCompareMode(event.target.checked)} />
|
|
812
|
+
<span>模型对比(多模型同参数生成)</span>
|
|
316
813
|
</label>
|
|
317
814
|
) : null}
|
|
318
815
|
|
|
319
|
-
{
|
|
320
|
-
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
<
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
816
|
+
{needModel ? (
|
|
817
|
+
compareMode ? (
|
|
818
|
+
<div className={css.compareBox}>
|
|
819
|
+
<span className={css.label}>对比模型(至少 2 个,最多 4 个)</span>
|
|
820
|
+
<div className={css.compareChips}>
|
|
821
|
+
{visibleModels.map(item => (
|
|
822
|
+
<button
|
|
823
|
+
key={item}
|
|
824
|
+
type="button"
|
|
825
|
+
className={css.compareChip}
|
|
826
|
+
data-active={compareModels.includes(item) ? 'true' : 'false'}
|
|
827
|
+
onClick={() => setCompareModels(current => current.includes(item)
|
|
828
|
+
? current.filter(candidate => candidate !== item)
|
|
829
|
+
: current.length < 4 ? [...current, item] : current)}
|
|
830
|
+
>
|
|
831
|
+
{item}
|
|
832
|
+
</button>
|
|
833
|
+
))}
|
|
834
|
+
{visibleModels.length === 0 ? <p className={css.hint}>当前模式暂无可用模型</p> : null}
|
|
835
|
+
</div>
|
|
836
|
+
<details className={css.advanced}>
|
|
837
|
+
<summary>每模型参数覆盖(默认自动:沿用上方相同配置)</summary>
|
|
838
|
+
<div className={css.overrideTable}>
|
|
839
|
+
<div className={css.overrideRow}>
|
|
840
|
+
<span className={`${css.overrideCell} ${css.overrideCellHead}`} />
|
|
841
|
+
{compareModels.map(item => <span key={item} className={`${css.overrideCell} ${css.overrideCellHead}`}>{item}</span>)}
|
|
842
|
+
</div>
|
|
843
|
+
{overrideRowSpecs(mode).map(row => (
|
|
844
|
+
<div key={row.key} className={css.overrideRow}>
|
|
845
|
+
<span className={css.overrideCell} title={`${row.hint ?? ''}${row.presets.length < 3 ? `(适用:${row.presets.map(presetLabel).join('/')})` : ''}`}>
|
|
846
|
+
{row.label}
|
|
847
|
+
{row.presets.length < 3 ? <span className={css.overrideOnly}> 仅{row.presets.map(presetLabel).join('/')}</span> : null}
|
|
848
|
+
</span>
|
|
849
|
+
{compareModels.map(item => {
|
|
850
|
+
const entry = modelOptions.models.find(candidate => candidate.alias === item)
|
|
851
|
+
const applicable = entry !== undefined && row.presets.includes(entry.preset)
|
|
852
|
+
if (!applicable) {
|
|
853
|
+
return <span key={item} className={css.overrideCell}><span className={css.overrideDash}>—</span></span>
|
|
854
|
+
}
|
|
855
|
+
const value = overrides[item]?.[row.key] ?? ''
|
|
856
|
+
return (
|
|
857
|
+
<span key={item} className={css.overrideCell}>
|
|
858
|
+
{row.type === 'select' ? (
|
|
859
|
+
<select className={css.input} value={value} onChange={event => {
|
|
860
|
+
setOverrides(current => ({
|
|
861
|
+
...current,
|
|
862
|
+
[item]: { ...(current[item] ?? {}), [row.key]: event.target.value },
|
|
863
|
+
}))
|
|
864
|
+
}}>
|
|
865
|
+
<option value="">自动</option>
|
|
866
|
+
{row.options!.map(option => <option key={option} value={option}>{option}</option>)}
|
|
867
|
+
</select>
|
|
868
|
+
) : (
|
|
869
|
+
<input
|
|
870
|
+
className={css.input}
|
|
871
|
+
type={row.type === 'number' ? 'number' : 'text'}
|
|
872
|
+
value={value}
|
|
873
|
+
placeholder={row.placeholder ?? '自动'}
|
|
874
|
+
onChange={event => {
|
|
875
|
+
setOverrides(current => ({
|
|
876
|
+
...current,
|
|
877
|
+
[item]: { ...(current[item] ?? {}), [row.key]: event.target.value },
|
|
878
|
+
}))
|
|
879
|
+
}}
|
|
880
|
+
/>
|
|
881
|
+
)}
|
|
882
|
+
</span>
|
|
883
|
+
)
|
|
884
|
+
})}
|
|
885
|
+
</div>
|
|
886
|
+
))}
|
|
887
|
+
</div>
|
|
888
|
+
</details>
|
|
360
889
|
</div>
|
|
890
|
+
) : (
|
|
361
891
|
<label className={css.label}>
|
|
362
|
-
<span
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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" />
|
|
892
|
+
<span>{tt('model.label')}</span>
|
|
893
|
+
<select className={css.input} value={model} onChange={event => setModel(event.target.value)}>
|
|
894
|
+
{visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
|
|
895
|
+
{groupedModels.map(group => (
|
|
896
|
+
<optgroup key={group.channelId} label={group.channelName}>
|
|
897
|
+
{group.models.map(item => <option key={item.alias} value={item.alias}>{item.alias}</option>)}
|
|
898
|
+
</optgroup>
|
|
899
|
+
))}
|
|
900
|
+
</select>
|
|
388
901
|
</label>
|
|
389
|
-
|
|
902
|
+
)
|
|
390
903
|
) : null}
|
|
391
904
|
|
|
392
|
-
{
|
|
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}
|
|
905
|
+
{globalSpecs.filter(spec => spec.advanced !== true).map(spec => renderField(spec))}
|
|
426
906
|
|
|
427
|
-
{
|
|
428
|
-
<
|
|
429
|
-
<
|
|
430
|
-
|
|
431
|
-
|
|
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>
|
|
907
|
+
{mode === 'tts' && globalSpecs.some(spec => spec.advanced === true) ? (
|
|
908
|
+
<details className={css.advanced}>
|
|
909
|
+
<summary>MiniMax 高级参数</summary>
|
|
910
|
+
{globalSpecs.filter(spec => spec.advanced === true).map(spec => renderField(spec))}
|
|
911
|
+
</details>
|
|
442
912
|
) : null}
|
|
443
913
|
|
|
444
914
|
<label className={css.checkbox} title="生成完成后自动保存到资源库;也可在设置中开启全部自动保存">
|
|
@@ -447,77 +917,78 @@ export function StudioView(props: {
|
|
|
447
917
|
</label>
|
|
448
918
|
|
|
449
919
|
{!connected && <p className={css.hint}>{tt('config.missing')}</p>}
|
|
450
|
-
<button
|
|
451
|
-
|
|
920
|
+
<button
|
|
921
|
+
type="button"
|
|
922
|
+
className={css.generate}
|
|
923
|
+
disabled={!connected || (compareMode && needModel ? compareModels.length < 2 : needModel && visibleModels.length === 0)}
|
|
924
|
+
onClick={submit}
|
|
925
|
+
>
|
|
926
|
+
{(compareMode && needModel ? '对比生成' : tt('generate'))}
|
|
452
927
|
</button>
|
|
928
|
+
{runningCount > 0 ? <p className={css.hint}>进行中任务:{runningCount} 个(并发上限在「设置 → 插件 → AI 音频」调整)</p> : null}
|
|
453
929
|
</div>
|
|
454
930
|
|
|
455
931
|
<div className={css.resultCol}>
|
|
456
932
|
{error !== null ? <p className={css.error}>{error}</p> : null}
|
|
457
|
-
{
|
|
933
|
+
{tasks.length === 0 ? (
|
|
458
934
|
<div className={css.resultEmpty}>
|
|
459
935
|
<span className={css.resultEmptyIcon}>🎵</span>
|
|
460
936
|
<p>{tt('result.empty')}</p>
|
|
461
|
-
<p className={css.resultEmptyHint}
|
|
937
|
+
<p className={css.resultEmptyHint}>点击「开始生成」即创建一个任务,可同时进行多个;勾选「模型对比」用多个模型同参数生成对比</p>
|
|
462
938
|
</div>
|
|
463
939
|
) : (
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
940
|
+
<div className={css.taskList}>
|
|
941
|
+
{tasks.map(task => {
|
|
942
|
+
const elapsed = task.finishedAt !== undefined
|
|
943
|
+
? Math.round((task.finishedAt - task.startedAt) / 1000)
|
|
944
|
+
: Math.round((Date.now() - task.startedAt) / 1000)
|
|
945
|
+
const statusText = task.status === 'running'
|
|
946
|
+
? `生成中 ${task.progress.done}/${task.progress.total}${task.progress.current !== '' ? ` · ${task.progress.current}` : ''} · ${elapsed}s`
|
|
947
|
+
: task.status === 'done'
|
|
948
|
+
? `完成 · ${task.groups.reduce((sum, group) => sum + group.outputs.length, 0)} 段 · ${elapsed}s`
|
|
949
|
+
: task.status === 'cancelled'
|
|
950
|
+
? '已取消'
|
|
951
|
+
: '失败'
|
|
952
|
+
return (
|
|
953
|
+
<div className={css.taskCard} key={task.id} data-state={task.status}>
|
|
954
|
+
<div className={css.taskHead}>
|
|
955
|
+
<span className={css.resultModeChip}>{task.mode}</span>
|
|
956
|
+
<span className={css.taskLabel} title={task.prompt}>{task.label}</span>
|
|
957
|
+
<span className={css.taskStatus} data-state={task.status}>{statusText}</span>
|
|
958
|
+
<span className={css.taskActions}>
|
|
959
|
+
{task.status === 'running' ? (
|
|
960
|
+
<button type="button" className={css.ghostButton} onClick={() => cancelTask(task.id)}>取消</button>
|
|
478
961
|
) : null}
|
|
479
|
-
<
|
|
480
|
-
</
|
|
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>
|
|
962
|
+
<button type="button" className={css.ghostButton} onClick={() => removeTask(task.id)}>移除</button>
|
|
963
|
+
</span>
|
|
516
964
|
</div>
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
965
|
+
{task.error !== undefined ? <p className={css.hint} data-error>{task.error}</p> : null}
|
|
966
|
+
<div className={css.compareBoard}>
|
|
967
|
+
{task.groups.map(group => (
|
|
968
|
+
<div className={css.compareGroup} key={group.model} data-state={group.state}>
|
|
969
|
+
<div className={css.compareGroupHead}>
|
|
970
|
+
<span className={css.compareModelName}>{group.model}</span>
|
|
971
|
+
<span className={css.compareState}>
|
|
972
|
+
{group.state === 'waiting' ? '等待中…'
|
|
973
|
+
: group.state === 'running' ? '生成中…'
|
|
974
|
+
: group.state === 'done' ? <><CheckIcon /> 完成</>
|
|
975
|
+
: group.state === 'cancelled' ? '已取消'
|
|
976
|
+
: '失败'}
|
|
977
|
+
</span>
|
|
978
|
+
</div>
|
|
979
|
+
{group.state === 'error' ? <p className={css.hint} data-error>{group.error}</p> : null}
|
|
980
|
+
{group.outputs.length > 0 ? (
|
|
981
|
+
<div className={css.audioList}>
|
|
982
|
+
{group.outputs.map((audio, index) => renderAudioCard(audio, index, group.model, group.model))}
|
|
983
|
+
</div>
|
|
984
|
+
) : null}
|
|
985
|
+
</div>
|
|
986
|
+
))}
|
|
987
|
+
</div>
|
|
988
|
+
</div>
|
|
989
|
+
)
|
|
990
|
+
})}
|
|
991
|
+
</div>
|
|
521
992
|
)}
|
|
522
993
|
</div>
|
|
523
994
|
|
|
@@ -527,22 +998,69 @@ export function StudioView(props: {
|
|
|
527
998
|
<button type="button" className={css.historyClear} onClick={clear}>清空</button>
|
|
528
999
|
</div>
|
|
529
1000
|
{entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
|
|
530
|
-
|
|
531
|
-
{
|
|
532
|
-
|
|
533
|
-
<
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1001
|
+
<>
|
|
1002
|
+
<div className={css.historyTabs}>
|
|
1003
|
+
{(['all', 'tts', 'music', 'sfx', 'voice_design'] as const).map(tab => (
|
|
1004
|
+
<button
|
|
1005
|
+
key={tab}
|
|
1006
|
+
type="button"
|
|
1007
|
+
className={css.historyTab}
|
|
1008
|
+
data-active={historyTab === tab ? 'true' : 'false'}
|
|
1009
|
+
onClick={() => setHistoryTab(tab)}
|
|
1010
|
+
>
|
|
1011
|
+
{tab === 'all' ? '全部' : modeLabelOf(tab)}
|
|
1012
|
+
<span className={css.historyTabCount}>
|
|
1013
|
+
{tab === 'all' ? entries.length : historyItems.filter(item => item.mode === tab).length}
|
|
1014
|
+
</span>
|
|
1015
|
+
</button>
|
|
1016
|
+
))}
|
|
1017
|
+
</div>
|
|
1018
|
+
<div className={css.historyList}>
|
|
1019
|
+
{(historyTab === 'all' ? historyItems : historyItems.filter(item => item.mode === historyTab)).map(item => {
|
|
1020
|
+
if (item.kind === 'compare') {
|
|
1021
|
+
return (
|
|
1022
|
+
<details className={css.historyItem} key={item.key} open>
|
|
1023
|
+
<summary className={css.historyCompareSummary}>
|
|
1024
|
+
<span className={css.historyPrompt}>{item.prompt}</span>
|
|
1025
|
+
<span className={css.historyCompareBadge}>对比 · {item.models.length} 个模型</span>
|
|
1026
|
+
</summary>
|
|
1027
|
+
<div className={css.historyMeta}>{modeLabelOf(item.mode)} · {item.models.map(model => model.model).join(' / ')}</div>
|
|
1028
|
+
{item.models.map(model => (
|
|
1029
|
+
<div key={model.entry.id} className={css.historyModelRow}>
|
|
1030
|
+
<div className={css.historyMeta}>
|
|
1031
|
+
<strong>{model.model}</strong>{model.channel !== undefined ? ` · ${model.channel}` : ''}
|
|
1032
|
+
</div>
|
|
1033
|
+
{model.entry.audio.map((audio, index) => (
|
|
1034
|
+
<AudioPlayer key={index} src={audio.url} compact itemKey={`${item.key}-${model.entry.id}-${index}`} />
|
|
1035
|
+
))}
|
|
1036
|
+
<div className={css.historyActions}>
|
|
1037
|
+
<button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(model.entry), contextOfEntry(model.entry))}>
|
|
1038
|
+
<StarIcon /> 入库
|
|
1039
|
+
</button>
|
|
1040
|
+
</div>
|
|
1041
|
+
</div>
|
|
1042
|
+
))}
|
|
1043
|
+
</details>
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
const entry = item.entry
|
|
1047
|
+
return (
|
|
1048
|
+
<div className={css.historyItem} key={item.key}>
|
|
1049
|
+
<div className={css.historyPrompt}>{entry.prompt}</div>
|
|
1050
|
+
<div className={css.historyMeta}>{modeLabelOf(entry.mode)} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
|
|
1051
|
+
{entry.audio.map((audio, index) => (
|
|
1052
|
+
<AudioPlayer key={index} src={audio.url} compact itemKey={`${entry.id}-${index}`} />
|
|
1053
|
+
))}
|
|
1054
|
+
<div className={css.historyActions}>
|
|
1055
|
+
<button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(entry), contextOfEntry(entry))}>
|
|
1056
|
+
<StarIcon /> 入库
|
|
1057
|
+
</button>
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
)
|
|
1061
|
+
})}
|
|
1062
|
+
</div>
|
|
1063
|
+
</>
|
|
546
1064
|
)}
|
|
547
1065
|
</aside>
|
|
548
1066
|
|