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.
@@ -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, { method: 'POST' })
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, { method: 'POST' })
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
- const [loading, setLoading] = useState(false)
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
- const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
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
- 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 => {
187
383
  if (prompt.trim() === '') {
188
384
  setError(tt('prompt.required'))
189
385
  return
190
386
  }
191
- setLoading(true)
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
- try {
194
- const response = await api.generate({
195
- mode,
196
- model: mode === 'voice_design' ? '' : (model || visibleModels[0]) ?? '',
197
- prompt: prompt.trim(),
198
- saveToLibrary,
199
- ...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
200
- ...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
201
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
202
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
203
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
204
- ...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
205
- ...(instrumental ? { isInstrumental: true } : {}),
206
- ...(loop ? { loop: true } : {}),
207
- ...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
208
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
209
- ...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
210
- ...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
211
- ...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
212
- ...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
213
- ...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
214
- ...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
215
- ...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
216
- ...(subtitle ? { subtitleEnable: true } : {}),
217
- })
218
- if (!response.ok) {
219
- setError(response.message ?? '生成失败')
220
- return
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
- const generated = response.outputs ?? []
223
- setOutputs(generated)
224
- if ((response.resources?.length ?? 0) > 0 && saveToLibrary) {
225
- setSavedIds(new Set(generated.map(item => item.id)))
226
- props.showToast('已保存到资源库')
227
- props.onLibraryChanged()
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
- reload()
230
- } catch (err) {
231
- setError(err instanceof Error ? err.message : String(err))
232
- } finally {
233
- setLoading(false)
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.label}>
304
- <span>{tt('model.label')}</span>
305
- <select className={css.input} value={model} onChange={event => setModel(event.target.value)}>
306
- {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
307
- {visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
308
- </select>
309
- </label>
310
- ) : null}
311
-
312
- {mode === 'tts' ? (
313
- <label className={css.label}>
314
- <span>{tt('voice.label')}</span>
315
- <input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
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
- {mode === 'tts' ? (
320
- <label className={css.label}>
321
- <span>{tt('speed.label')}</span>
322
- <input className={css.input} type="number" step="0.1" min="0.5" max="2" value={speed} onChange={event => setSpeed(event.target.value)} placeholder="1.0" />
323
- </label>
324
- ) : null}
325
-
326
- {mode === 'tts' && isMiniMaxChannel ? (
327
- <details className={css.advanced}>
328
- <summary>MiniMax 高级参数</summary>
329
- <label className={css.label}>
330
- <span>情绪 emotion</span>
331
- <input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
332
- </label>
333
- <div className={css.row}>
334
- <label className={css.label}>
335
- <span>音量 vol (0-10)</span>
336
- <input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
337
- </label>
338
- <label className={css.label}>
339
- <span>音调 pitch (-12~12)</span>
340
- <input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
341
- </label>
342
- </div>
343
- <div className={css.row}>
344
- <label className={css.label}>
345
- <span>采样率</span>
346
- <input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
347
- </label>
348
- <label className={css.label}>
349
- <span>码率 bps</span>
350
- <input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
351
- </label>
352
- <label className={css.label}>
353
- <span>声道</span>
354
- <select className={css.input} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
355
- <option value="">默认(1)</option>
356
- <option value="1">1</option>
357
- <option value="2">2</option>
358
- </select>
359
- </label>
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>发音词典(每行一条:"文字/读音")</span>
363
- <textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
364
- </label>
365
- <label className={css.checkbox}>
366
- <input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
367
- <span>生成字幕 subtitle_enable</span>
368
- </label>
369
- </details>
370
- ) : null}
371
-
372
- {mode === 'music' || mode === 'sfx' ? (
373
- <label className={css.label}>
374
- <span>{tt('duration.label')}</span>
375
- <input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
376
- </label>
377
- ) : null}
378
-
379
- {mode === 'sfx' ? (
380
- <>
381
- <label className={css.checkbox}>
382
- <input type="checkbox" checked={loop} onChange={event => setLoop(event.target.checked)} />
383
- <span>循环音效 loop(无缝循环,需 eleven_text_to_sound_v2)</span>
384
- </label>
385
- <label className={css.label}>
386
- <span>提示词影响度 prompt_influence (0-1)</span>
387
- <input className={css.input} type="number" step="0.1" min="0" max="1" value={promptInfluence} onChange={event => setPromptInfluence(event.target.value)} placeholder="0.3" />
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
- {mode === 'music' ? (
393
- <>
394
- <label className={css.label}>
395
- <span>歌词(纯音乐模式可留空;多段用空行分隔)</span>
396
- <textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
397
- </label>
398
- <label className={css.checkbox}>
399
- <input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
400
- <span>纯音乐(无歌词/人声)is_instrumental</span>
401
- </label>
402
- <div className={css.row}>
403
- <label className={css.label}>
404
- <span>采样率</span>
405
- <select className={css.input} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
406
- <option value="">默认(44100)</option>
407
- <option value="16000">16000</option>
408
- <option value="24000">24000</option>
409
- <option value="32000">32000</option>
410
- <option value="44100">44100</option>
411
- </select>
412
- </label>
413
- <label className={css.label}>
414
- <span>码率 bps</span>
415
- <select className={css.input} value={bitrate} onChange={event => setBitrate(event.target.value)}>
416
- <option value="">默认(256000)</option>
417
- <option value="32000">32000</option>
418
- <option value="64000">64000</option>
419
- <option value="128000">128000</option>
420
- <option value="256000">256000</option>
421
- </select>
422
- </label>
423
- </div>
424
- </>
425
- ) : null}
905
+ {globalSpecs.filter(spec => spec.advanced !== true).map(spec => renderField(spec))}
426
906
 
427
- {needModel ? (
428
- <label className={css.label}>
429
- <span>{tt('format.label')}</span>
430
- <select className={css.input} value={format} onChange={event => setFormat(event.target.value)}>
431
- <option value="mp3">mp3</option>
432
- <option value="wav">wav</option>
433
- {mode === 'tts' ? (
434
- <>
435
- <option value="flac">flac</option>
436
- <option value="ogg">ogg</option>
437
- </>
438
- ) : null}
439
- <option value="pcm">pcm</option>
440
- </select>
441
- </label>
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 type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
451
- {loading ? 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'))}
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
- {outputs.length === 0 ? (
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}>生成结果将在这里播放、下载,并可一键加入资源库</p>
937
+ <p className={css.resultEmptyHint}>点击「开始生成」即创建一个任务,可同时进行多个;勾选「模型对比」用多个模型同参数生成对比</p>
462
938
  </div>
463
939
  ) : (
464
- <>
465
- <div className={css.resultMeta}>
466
- <span>{tt('result.done', { count: outputs.length })}</span>
467
- <span className={css.resultModeChip}>{modeLabel}</span>
468
- </div>
469
- <div className={css.audioList}>
470
- {outputs.map((audio, index) => {
471
- const saved = savedIds.has(audio.id)
472
- return (
473
- <div className={css.audioCard} key={audio.id} data-saved={saved ? 'true' : 'false'}>
474
- <div className={css.audioCardHead}>
475
- {audio.voiceId !== undefined ? <span className={css.voiceIdChip} title="新音色 ID">新音色 {audio.voiceId}</span> : null}
476
- {saved ? (
477
- <span className={css.savedChip}><CheckIcon /> 已入库</span>
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
- <span className={css.audioCardIndex}>#{index + 1}</span>
480
- </div>
481
- <AudioPlayer src={dataUrlOf(audio)} itemKey={audio.id} />
482
- <div className={css.audioCardActions}>
483
- <a className={css.ghostButton} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>
484
- <DownloadIcon /> 下载
485
- </a>
486
- {saved ? (
487
- <button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
488
- <CheckIcon /> 已入库
489
- </button>
490
- ) : (
491
- <button
492
- type="button"
493
- className={css.ghostButton}
494
- onClick={() => openSaveDialog([audio], {
495
- mode,
496
- prompt: prompt.trim(),
497
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
498
- ...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
499
- ...(model !== '' ? { model } : {}),
500
- ...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
501
- params: {
502
- mode,
503
- model: mode === 'voice_design' ? '' : (model || visibleModels[0]) ?? '',
504
- prompt: prompt.trim(),
505
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
506
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
507
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
508
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
509
- },
510
- })}
511
- >
512
- <StarIcon /> 加入资源库
513
- </button>
514
- )}
515
- </div>
962
+ <button type="button" className={css.ghostButton} onClick={() => removeTask(task.id)}>移除</button>
963
+ </span>
516
964
  </div>
517
- )
518
- })}
519
- </div>
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
- <div className={css.historyList}>
531
- {entries.map(entry => (
532
- <div className={css.historyItem} key={entry.id}>
533
- <div className={css.historyPrompt}>{entry.prompt}</div>
534
- <div className={css.historyMeta}>{entry.mode} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
535
- {entry.audio.map((audio, index) => (
536
- <AudioPlayer key={index} src={audio.url} compact itemKey={`${entry.id}-${index}`} />
537
- ))}
538
- <div className={css.historyActions}>
539
- <button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(entry), contextOfEntry(entry))}>
540
- <StarIcon /> 入库
541
- </button>
542
- </div>
543
- </div>
544
- ))}
545
- </div>
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