dsh-audiogen 0.4.1 → 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.
@@ -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 GenerateAudioRequest, 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,25 +29,33 @@ export interface StudioReuse {
26
29
  voiceId?: string
27
30
  }
28
31
 
29
- /** 每模型可覆盖的参数行(留空 = 自动,沿用上方全局配置)。 */
30
- const OVERRIDE_ROWS: Array<{ key: string; label: string; type: 'text' | 'number' | 'select'; options?: string[]; placeholder?: string }> = [
31
- { key: 'format', label: '输出格式', type: 'select', options: ['mp3', 'wav', 'pcm', 'flac', 'ogg'], placeholder: '自动' },
32
- { key: 'duration', label: '时长(秒)', type: 'number', placeholder: '自动' },
33
- { key: 'voice', label: '音色', type: 'text', placeholder: '自动' },
34
- { key: 'speed', label: '语速', type: 'number', placeholder: '自动' },
35
- { key: 'emotion', label: '情绪', type: 'text', placeholder: '自动' },
36
- { key: 'sample_rate', label: '采样率', type: 'number', placeholder: '自动' },
37
- { key: 'bitrate', label: '码率', type: 'number', placeholder: '自动' },
38
- { key: 'lyrics', label: '歌词', type: 'text', placeholder: '自动' },
39
- { key: 'seed', label: 'seed', type: 'number', placeholder: '自动' },
40
- { key: 'steps', label: 'steps', type: 'number', placeholder: '自动' },
41
- { key: 'cfg_scale', label: 'cfg_scale', type: 'number', placeholder: '自动' },
42
- ]
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
+ }
43
54
 
44
55
  /** 每模型覆盖值 → 请求字段的数值/类型转换(空值跳过)。 */
45
56
  function overrideSpread(override: Record<string, string>): Partial<GenerateAudioRequest> {
46
57
  const out: Partial<GenerateAudioRequest> = {}
47
- const val = override.format?.trim() ?? ''
58
+ const val = (override.format ?? '').trim()
48
59
  if (val !== '') out.format = val
49
60
  const num = (key: string): number | undefined => {
50
61
  const raw = (override[key] ?? '').trim()
@@ -54,17 +65,17 @@ function overrideSpread(override: Record<string, string>): Partial<GenerateAudio
54
65
  }
55
66
  const duration = num('duration')
56
67
  if (duration !== undefined) out.duration = duration
57
- const voice = override.voice?.trim() ?? ''
68
+ const voice = (override.voice ?? '').trim()
58
69
  if (voice !== '') out.voice = voice
59
70
  const speed = num('speed')
60
71
  if (speed !== undefined) out.speed = speed
61
- const emotion = override.emotion?.trim() ?? ''
72
+ const emotion = (override.emotion ?? '').trim()
62
73
  if (emotion !== '') out.emotion = emotion
63
74
  const sampleRate = num('sample_rate')
64
75
  if (sampleRate !== undefined) out.sampleRate = sampleRate
65
76
  const bitrate = num('bitrate')
66
77
  if (bitrate !== undefined) out.bitrate = bitrate
67
- const lyrics = override.lyrics?.trim() ?? ''
78
+ const lyrics = (override.lyrics ?? '').trim()
68
79
  if (lyrics !== '') out.lyrics = lyrics
69
80
  const seed = num('seed')
70
81
  if (seed !== undefined) out.seed = seed
@@ -75,6 +86,21 @@ function overrideSpread(override: Record<string, string>): Partial<GenerateAudio
75
86
  return out
76
87
  }
77
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
+
78
104
  function useConfig(scope: AudiogenScope) {
79
105
  const [value, setValue] = useState(scope.getSnapshot().value)
80
106
  useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
@@ -84,7 +110,11 @@ function useConfig(scope: AudiogenScope) {
84
110
  function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
85
111
  const [entries, setEntries] = useState<HistoryEntry[]>([])
86
112
  const reload = (): void => {
87
- void fetch(HISTORY_API.list, { method: 'POST' })
113
+ void fetch(HISTORY_API.list, {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: '{}',
117
+ })
88
118
  .then(async response => {
89
119
  const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
90
120
  if (body.ok === true) setEntries(body.history ?? [])
@@ -93,7 +123,11 @@ function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: ()
93
123
  }
94
124
  useEffect(() => { reload() }, [])
95
125
  const clear = (): void => {
96
- void fetch(HISTORY_API.clear, { method: 'POST' })
126
+ void fetch(HISTORY_API.clear, {
127
+ method: 'POST',
128
+ headers: { 'content-type': 'application/json' },
129
+ body: '{}',
130
+ })
97
131
  .then(() => reload())
98
132
  .catch(() => { /* best-effort */ })
99
133
  }
@@ -170,10 +204,6 @@ export function StudioView(props: {
170
204
  const [prompt, setPrompt] = useState('')
171
205
  const [previewText, setPreviewText] = useState('')
172
206
  const [model, setModel] = useState('')
173
- // 多模型对比:同一提示词逐模型生成
174
- const [selectedModels, setSelectedModels] = useState<string[]>([])
175
- // 每模型参数覆盖(留空 = 自动沿用全局配置)
176
- const [overrides, setOverrides] = useState<Record<string, Record<string, string>>>({})
177
207
  const [voice, setVoice] = useState('')
178
208
  const [speed, setSpeed] = useState('')
179
209
  const [duration, setDuration] = useState('')
@@ -192,14 +222,27 @@ export function StudioView(props: {
192
222
  const [bitrate, setBitrate] = useState('')
193
223
  const [audioChannel, setAudioChannel] = useState('')
194
224
  const [subtitle, setSubtitle] = useState(false)
195
- const [loading, setLoading] = useState(false)
196
- const [progress, setProgress] = useState('')
225
+ // Stable Audio 参数(仅 Stability 渠道显示)
226
+ const [seed, setSeed] = useState('')
227
+ const [steps, setSteps] = useState('')
228
+ const [cfgScale, setCfgScale] = useState('')
197
229
  const [error, setError] = useState<string | null>(null)
198
- const [results, setResults] = useState<Array<{ model: string; outputs: GeneratedAudio[]; error?: string }>>([])
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[]>())
199
235
  // 资源库
200
236
  const [saveToLibrary, setSaveToLibrary] = useState(cfg?.autoSaveToLibrary === true)
201
237
  const [savedIds, setSavedIds] = useState<Set<string>>(new Set())
202
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>>>({})
203
246
  const { entries, reload, clear } = useHistory()
204
247
  // 音色设计模式的厂商/渠道选择(默认渠道)
205
248
  const [designChannelId, setDesignChannelId] = useState('')
@@ -223,32 +266,43 @@ export function StudioView(props: {
223
266
  .map(entry => entry.alias)
224
267
  }, [modelOptions.models, mode])
225
268
 
226
- useEffect(() => {
227
- if (visibleModels.length === 0) return
228
- setSelectedModels(current => {
229
- const valid = current.filter(item => visibleModels.includes(item))
230
- if (valid.length > 0) return valid
231
- return [visibleModels[0]!]
232
- })
233
- }, [visibleModels])
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])
234
275
 
235
- // 每模型覆盖值随勾选模型收缩(保留仍在勾选中的覆盖)
236
- useEffect(() => {
237
- setOverrides(current => {
238
- const next: Record<string, Record<string, string>> = {}
239
- for (const alias of selectedModels) {
240
- if (current[alias] !== undefined) next[alias] = current[alias]!
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)
241
283
  }
242
- return next
243
- })
244
- }, [selectedModels])
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])
245
289
 
246
- const setOverrideValue = (model: string, key: string, value: string): void => {
247
- setOverrides(current => ({
248
- ...current,
249
- [model]: { ...(current[model] ?? {}), [key]: value },
250
- }))
251
- }
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])
252
306
 
253
307
  useEffect(() => {
254
308
  if (visibleModels.length > 0 && !visibleModels.includes(model)) {
@@ -256,103 +310,345 @@ export function StudioView(props: {
256
310
  }
257
311
  }, [visibleModels, model])
258
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
+
259
329
  // 资源库「用此音色」回填
260
330
  useEffect(() => {
261
331
  if (reuse === undefined || reuse === null) return
262
332
  setMode(reuse.mode)
263
333
  if (reuse.voiceId !== undefined || reuse.voice !== undefined) setVoice(reuse.voiceId ?? reuse.voice ?? '')
264
- if (reuse.model !== undefined && reuse.model !== '') {
265
- setModel(reuse.model)
266
- setSelectedModels([reuse.model])
267
- }
334
+ if (reuse.model !== undefined && reuse.model !== '') setModel(reuse.model)
268
335
  }, [reuse?.nonce])
269
336
 
270
- const submit = async (): Promise<void> => {
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 => {
271
383
  if (prompt.trim() === '') {
272
384
  setError(tt('prompt.required'))
273
385
  return
274
386
  }
275
- // 待生成模型列表:音色设计单渠道;其余支持多模型(同一提示词逐一生成,供对比)。
276
- const targets = mode === 'voice_design'
277
- ? ['']
278
- : selectedModels.length > 0
279
- ? selectedModels
280
- : (model !== '' ? [model] : visibleModels.length > 0 ? [visibleModels[0]!] : [])
281
- if (targets.length === 0) {
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 === '') {
282
395
  setError('当前模式暂无可用模型')
283
396
  return
284
397
  }
285
- setLoading(true)
286
398
  setError(null)
287
- setProgress('')
288
- const gathered: Array<{ model: string; outputs: GeneratedAudio[]; error?: string }> = []
289
- let anySaved = false
290
- try {
291
- for (const [index, target] of targets.entries()) {
292
- setProgress(`生成中 ${index + 1}/${targets.length}(${target || '音色设计'})…`)
293
- let response
294
- try {
295
- response = await api.generate({
296
- mode,
297
- model: target,
298
- prompt: prompt.trim(),
299
- saveToLibrary,
300
- ...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
301
- ...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
302
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
303
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
304
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
305
- ...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
306
- ...(instrumental ? { isInstrumental: true } : {}),
307
- ...(loop ? { loop: true } : {}),
308
- ...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
309
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
310
- ...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
311
- ...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
312
- ...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
313
- ...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
314
- ...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
315
- ...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
316
- ...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
317
- ...(subtitle ? { subtitleEnable: true } : {}),
318
- // 每模型参数覆盖(后置覆盖全局;仅音色设计外生效)
319
- ...(target === '' ? {} : overrideSpread(overrides[target] ?? {})),
320
- })
321
- } catch (err) {
322
- gathered.push({ model: target, outputs: [], error: err instanceof Error ? err.message : String(err) })
323
- continue
324
- }
325
- if (!response.ok) {
326
- gathered.push({ model: target, outputs: [], error: response.message ?? '生成失败' })
327
- continue
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
+ }))
328
460
  }
329
- const generated = response.outputs ?? []
330
- gathered.push({ model: target, outputs: generated })
331
- if ((response.resources?.length ?? 0) > 0 && saveToLibrary) anySaved = true
332
- }
333
- setResults(gathered)
334
- if (anySaved) {
335
- setSavedIds(current => new Set([...current, ...gathered.flatMap(group => group.outputs.map(item => item.id))]))
336
- props.showToast('已保存到资源库')
337
- props.onLibraryChanged()
461
+ } finally {
462
+ patchTask(taskId, task => ({ ...task, progress: { ...task.progress, done: task.progress.done + 1, current: '' } }))
338
463
  }
339
- const failed = gathered.filter(group => group.error !== undefined && group.outputs.length === 0)
340
- if (failed.length > 0 && failed.length === gathered.length) {
341
- setError(failed.map(group => `「${group.model || '音色设计'}」${group.error}`).join(';'))
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(';') : '生成失败',
342
479
  }
343
- reload()
344
- } catch (err) {
345
- setError(err instanceof Error ? err.message : String(err))
346
- } finally {
347
- setLoading(false)
348
- setProgress('')
349
- }
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))
350
500
  }
351
501
 
352
502
  const openSaveDialog = (files: GeneratedAudio[], context: SaveDialogContext): void => {
353
503
  setSaveDialog({ files, context })
354
504
  }
355
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
+
356
652
  const onDialogSaved = (entry: LibraryEntry): void => {
357
653
  if (saveDialog !== null) {
358
654
  setSavedIds(current => new Set([...current, ...saveDialog.files.map(file => file.id)]))
@@ -362,6 +658,49 @@ export function StudioView(props: {
362
658
  props.onLibraryChanged()
363
659
  }
364
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
+
365
704
  const modeLabel = useMemo(() => {
366
705
  if (mode === 'tts') return tt('mode.tts')
367
706
  if (mode === 'music') return tt('mode.music')
@@ -369,7 +708,60 @@ export function StudioView(props: {
369
708
  return tt('mode.voiceDesign')
370
709
  }, [mode])
371
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
+
372
763
  const needModel = mode !== 'voice_design'
764
+ const runningCount = tasks.filter(task => task.status === 'running').length
373
765
 
374
766
  return (
375
767
  <div className={css.studio}>
@@ -415,288 +807,188 @@ export function StudioView(props: {
415
807
  ) : null}
416
808
 
417
809
  {needModel ? (
418
- <label className={css.label}>
419
- <span>{tt('model.label')}(可多选,同一提示词逐个生成以对比)</span>
420
- <div className={css.modelCheckList}>
421
- {visibleModels.length === 0 ? <p className={css.hint}>(当前模式暂无可用模型)</p> : null}
422
- {visibleModels.map(item => (
423
- <label key={item} className={css.checkbox}>
424
- <input
425
- type="checkbox"
426
- checked={selectedModels.includes(item)}
427
- onChange={event => {
428
- const checked = event.target.checked
429
- setSelectedModels(current => checked ? [...new Set([...current, item])] : current.filter(alias => alias !== item))
430
- if (checked && (model === '' || !visibleModels.includes(model))) setModel(item)
431
- }}
432
- />
433
- <span title="点击生成对比">{item}</span>
434
- </label>
435
- ))}
436
- </div>
810
+ <label className={css.checkbox} title="选择多个模型,用相同参数逐个生成,便于对比效果">
811
+ <input type="checkbox" checked={compareMode} onChange={event => setCompareMode(event.target.checked)} />
812
+ <span>模型对比(多模型同参数生成)</span>
437
813
  </label>
438
814
  ) : null}
439
815
 
440
- {needModel && selectedModels.length > 0 ? (
441
- <details className={css.advanced}>
442
- <summary>每模型参数覆盖(默认自动:沿用上方相同配置,如输出格式均为 mp3)</summary>
443
- <div className={css.overrideTable}>
444
- <div className={css.overrideRow}>
445
- <span className={`${css.overrideCell} ${css.overrideCellHead}`} />
446
- {selectedModels.map(item => <span key={item} className={`${css.overrideCell} ${css.overrideCellHead}`}>{item}</span>)}
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}
447
835
  </div>
448
- {OVERRIDE_ROWS.map(row => (
449
- <div key={row.key} className={css.overrideRow}>
450
- <span className={css.overrideCell} title={row.label}>{row.label}</span>
451
- {selectedModels.map(item => {
452
- const value = overrides[item]?.[row.key] ?? ''
453
- return (
454
- <span key={item} className={css.overrideCell}>
455
- {row.type === 'select' ? (
456
- <select className={css.input} value={value} onChange={event => setOverrideValue(item, row.key, event.target.value)}>
457
- <option value="">自动</option>
458
- {row.options!.map(option => <option key={option} value={option}>{option}</option>)}
459
- </select>
460
- ) : (
461
- <input
462
- className={css.input}
463
- type={row.type === 'number' ? 'number' : 'text'}
464
- value={value}
465
- placeholder={row.placeholder ?? '自动'}
466
- onChange={event => setOverrideValue(item, row.key, event.target.value)}
467
- />
468
- )}
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}
469
848
  </span>
470
- )
471
- })}
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
+ ))}
472
887
  </div>
473
- ))}
888
+ </details>
474
889
  </div>
475
- </details>
476
- ) : null}
477
-
478
- {mode === 'tts' ? (
479
- <label className={css.label}>
480
- <span>{tt('voice.label')}</span>
481
- <input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
482
- </label>
890
+ ) : (
891
+ <label className={css.label}>
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>
901
+ </label>
902
+ )
483
903
  ) : null}
484
904
 
485
- {mode === 'tts' ? (
486
- <label className={css.label}>
487
- <span>{tt('speed.label')}</span>
488
- <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" />
489
- </label>
490
- ) : null}
905
+ {globalSpecs.filter(spec => spec.advanced !== true).map(spec => renderField(spec))}
491
906
 
492
- {mode === 'tts' && isMiniMaxChannel ? (
907
+ {mode === 'tts' && globalSpecs.some(spec => spec.advanced === true) ? (
493
908
  <details className={css.advanced}>
494
909
  <summary>MiniMax 高级参数</summary>
495
- <label className={css.label}>
496
- <span>情绪 emotion</span>
497
- <input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
498
- </label>
499
- <div className={css.row}>
500
- <label className={css.label}>
501
- <span>音量 vol (0-10)</span>
502
- <input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
503
- </label>
504
- <label className={css.label}>
505
- <span>音调 pitch (-12~12)</span>
506
- <input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
507
- </label>
508
- </div>
509
- <div className={css.row}>
510
- <label className={css.label}>
511
- <span>采样率</span>
512
- <input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
513
- </label>
514
- <label className={css.label}>
515
- <span>码率 bps</span>
516
- <input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
517
- </label>
518
- <label className={css.label}>
519
- <span>声道</span>
520
- <select className={css.input} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
521
- <option value="">默认(1)</option>
522
- <option value="1">1</option>
523
- <option value="2">2</option>
524
- </select>
525
- </label>
526
- </div>
527
- <label className={css.label}>
528
- <span>发音词典(每行一条:"文字/读音")</span>
529
- <textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
530
- </label>
531
- <label className={css.checkbox}>
532
- <input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
533
- <span>生成字幕 subtitle_enable</span>
534
- </label>
910
+ {globalSpecs.filter(spec => spec.advanced === true).map(spec => renderField(spec))}
535
911
  </details>
536
912
  ) : null}
537
913
 
538
- {mode === 'music' || mode === 'sfx' ? (
539
- <label className={css.label}>
540
- <span>{tt('duration.label')}</span>
541
- <input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
542
- </label>
543
- ) : null}
544
-
545
- {mode === 'sfx' ? (
546
- <>
547
- <label className={css.checkbox}>
548
- <input type="checkbox" checked={loop} onChange={event => setLoop(event.target.checked)} />
549
- <span>循环音效 loop(无缝循环,需 eleven_text_to_sound_v2)</span>
550
- </label>
551
- <label className={css.label}>
552
- <span>提示词影响度 prompt_influence (0-1)</span>
553
- <input className={css.input} type="number" step="0.1" min="0" max="1" value={promptInfluence} onChange={event => setPromptInfluence(event.target.value)} placeholder="0.3" />
554
- </label>
555
- </>
556
- ) : null}
557
-
558
- {mode === 'music' ? (
559
- <>
560
- <label className={css.label}>
561
- <span>歌词(纯音乐模式可留空;多段用空行分隔)</span>
562
- <textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
563
- </label>
564
- <label className={css.checkbox}>
565
- <input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
566
- <span>纯音乐(无歌词/人声)is_instrumental</span>
567
- </label>
568
- <div className={css.row}>
569
- <label className={css.label}>
570
- <span>采样率</span>
571
- <select className={css.input} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
572
- <option value="">默认(44100)</option>
573
- <option value="16000">16000</option>
574
- <option value="24000">24000</option>
575
- <option value="32000">32000</option>
576
- <option value="44100">44100</option>
577
- </select>
578
- </label>
579
- <label className={css.label}>
580
- <span>码率 bps</span>
581
- <select className={css.input} value={bitrate} onChange={event => setBitrate(event.target.value)}>
582
- <option value="">默认(256000)</option>
583
- <option value="32000">32000</option>
584
- <option value="64000">64000</option>
585
- <option value="128000">128000</option>
586
- <option value="256000">256000</option>
587
- </select>
588
- </label>
589
- </div>
590
- </>
591
- ) : null}
592
-
593
- {needModel ? (
594
- <label className={css.label}>
595
- <span>{tt('format.label')}</span>
596
- <select className={css.input} value={format} onChange={event => setFormat(event.target.value)}>
597
- <option value="mp3">mp3</option>
598
- <option value="wav">wav</option>
599
- {mode === 'tts' ? (
600
- <>
601
- <option value="flac">flac</option>
602
- <option value="ogg">ogg</option>
603
- </>
604
- ) : null}
605
- <option value="pcm">pcm</option>
606
- </select>
607
- </label>
608
- ) : null}
609
-
610
914
  <label className={css.checkbox} title="生成完成后自动保存到资源库;也可在设置中开启全部自动保存">
611
915
  <input type="checkbox" checked={saveToLibrary} onChange={event => setSaveToLibrary(event.target.checked)} />
612
916
  <span>生成后保存到资源库</span>
613
917
  </label>
614
918
 
615
919
  {!connected && <p className={css.hint}>{tt('config.missing')}</p>}
616
- <button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
617
- {loading ? (progress !== '' ? progress : tt('generating')) : tt('generate')}
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'))}
618
927
  </button>
928
+ {runningCount > 0 ? <p className={css.hint}>进行中任务:{runningCount} 个(并发上限在「设置 → 插件 → AI 音频」调整)</p> : null}
619
929
  </div>
620
930
 
621
931
  <div className={css.resultCol}>
622
932
  {error !== null ? <p className={css.error}>{error}</p> : null}
623
- {results.length === 0 ? (
933
+ {tasks.length === 0 ? (
624
934
  <div className={css.resultEmpty}>
625
935
  <span className={css.resultEmptyIcon}>🎵</span>
626
936
  <p>{tt('result.empty')}</p>
627
- <p className={css.resultEmptyHint}>生成结果将在这里播放、下载,并可一键加入资源库</p>
937
+ <p className={css.resultEmptyHint}>点击「开始生成」即创建一个任务,可同时进行多个;勾选「模型对比」用多个模型同参数生成对比</p>
628
938
  </div>
629
939
  ) : (
630
- <>
631
- <div className={css.resultMeta}>
632
- <span>{tt('result.done', { count: results.reduce((sum, group) => sum + group.outputs.length, 0) })}</span>
633
- <span className={css.resultModeChip}>{modeLabel}</span>
634
- </div>
635
- <div className={css.resultGroups}>
636
- {results.map((group, groupIndex) => (
637
- <div className={css.resultGroup} key={`${group.model}-${groupIndex}`}>
638
- <div className={css.resultGroupHead}>
639
- <span className={css.resultGroupChip}>{group.model || '音色设计'}</span>
640
- {group.error !== undefined ? <span className={css.resultGroupError}>生成失败:{group.error}</span> : null}
641
- {group.outputs.length > 0 ? <span className={css.resultGroupCount}>{group.outputs.length} 段</span> : null}
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>
961
+ ) : null}
962
+ <button type="button" className={css.ghostButton} onClick={() => removeTask(task.id)}>移除</button>
963
+ </span>
642
964
  </div>
643
- {group.outputs.length > 0 ? (
644
- <div className={css.audioList}>
645
- {group.outputs.map((audio, index) => {
646
- const saved = savedIds.has(audio.id)
647
- return (
648
- <div className={css.audioCard} key={audio.id} data-saved={saved ? 'true' : 'false'}>
649
- <div className={css.audioCardHead}>
650
- {audio.voiceId !== undefined ? <span className={css.voiceIdChip} title="新音色 ID">新音色 {audio.voiceId}</span> : null}
651
- {saved ? (
652
- <span className={css.savedChip}><CheckIcon /> 已入库</span>
653
- ) : null}
654
- <span className={css.audioCardIndex}>#{index + 1}</span>
655
- </div>
656
- <AudioPlayer src={dataUrlOf(audio)} itemKey={audio.id} />
657
- <div className={css.audioCardActions}>
658
- <a className={css.ghostButton} href={dataUrlOf(audio)} download={`generated-${groupIndex + 1}-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>
659
- <DownloadIcon /> 下载
660
- </a>
661
- {saved ? (
662
- <button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
663
- <CheckIcon /> 已入库
664
- </button>
665
- ) : (
666
- <button
667
- type="button"
668
- className={css.ghostButton}
669
- onClick={() => openSaveDialog([audio], {
670
- mode,
671
- prompt: prompt.trim(),
672
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
673
- ...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
674
- ...(group.model !== '' ? { model: group.model } : {}),
675
- ...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
676
- params: {
677
- mode,
678
- model: mode === 'voice_design' ? '' : group.model,
679
- prompt: prompt.trim(),
680
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
681
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
682
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
683
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
684
- },
685
- })}
686
- >
687
- <StarIcon /> 加入资源库
688
- </button>
689
- )}
690
- </div>
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))}
691
983
  </div>
692
- )
693
- })}
694
- </div>
695
- ) : null}
984
+ ) : null}
985
+ </div>
986
+ ))}
987
+ </div>
696
988
  </div>
697
- ))}
698
- </div>
699
- </>
989
+ )
990
+ })}
991
+ </div>
700
992
  )}
701
993
  </div>
702
994
 
@@ -706,22 +998,69 @@ export function StudioView(props: {
706
998
  <button type="button" className={css.historyClear} onClick={clear}>清空</button>
707
999
  </div>
708
1000
  {entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
709
- <div className={css.historyList}>
710
- {entries.map(entry => (
711
- <div className={css.historyItem} key={entry.id}>
712
- <div className={css.historyPrompt}>{entry.prompt}</div>
713
- <div className={css.historyMeta}>{entry.mode} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
714
- {entry.audio.map((audio, index) => (
715
- <AudioPlayer key={index} src={audio.url} compact itemKey={`${entry.id}-${index}`} />
716
- ))}
717
- <div className={css.historyActions}>
718
- <button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(entry), contextOfEntry(entry))}>
719
- <StarIcon /> 入库
720
- </button>
721
- </div>
722
- </div>
723
- ))}
724
- </div>
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
+ </>
725
1064
  )}
726
1065
  </aside>
727
1066