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.
@@ -28,6 +28,7 @@ export interface AudioGenSettings {
28
28
  allowAgentAudioGeneration?: boolean
29
29
  defaultModel?: string
30
30
  autoSaveToLibrary?: boolean
31
+ maxConcurrentGenerations?: number
31
32
  }
32
33
 
33
34
  export interface AudioGenSettingsCardState extends CardShell {
@@ -37,6 +38,7 @@ export interface AudioGenSettingsCardState extends CardShell {
37
38
  allowAgentAudioGeneration: CardFieldState
38
39
  defaultModel: CardFieldState
39
40
  autoSaveToLibrary: CardFieldState
41
+ maxConcurrentGenerations: CardFieldState
40
42
  }
41
43
 
42
44
  export interface AudioGenSettingsCardFace extends CardActions {
@@ -57,6 +59,7 @@ export class AudioGenSettingsCardController {
57
59
  booleanField('allowAgentAudioGeneration'),
58
60
  textField('defaultModel'),
59
61
  booleanField('autoSaveToLibrary'),
62
+ textField('maxConcurrentGenerations'),
60
63
  ])
61
64
  this.channelsForm = new ChannelsForm(scope)
62
65
  }
@@ -72,6 +75,7 @@ export class AudioGenSettingsCardController {
72
75
  allowAgentAudioGeneration: this.form.field('allowAgentAudioGeneration'),
73
76
  defaultModel: this.form.field('defaultModel'),
74
77
  autoSaveToLibrary: this.form.field('autoSaveToLibrary'),
78
+ maxConcurrentGenerations: this.form.field('maxConcurrentGenerations'),
75
79
  }
76
80
  }
77
81
 
@@ -723,6 +727,20 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
723
727
  <input type="checkbox" checked={state.autoSaveToLibrary.text === 'true'} disabled={!state.writable} onChange={event => props.edit('autoSaveToLibrary', String(event.target.checked))} /> {t('settings.autoSaveLibrary')}
724
728
  </label>
725
729
  </div>
730
+ <div className={css.field}>
731
+ <label className={css.label}>
732
+ <span>{t('settings.maxConcurrent')}</span>
733
+ <input
734
+ type="number"
735
+ min="1"
736
+ max="20"
737
+ className={css.input}
738
+ value={state.maxConcurrentGenerations.text}
739
+ disabled={!state.writable}
740
+ onChange={event => props.edit('maxConcurrentGenerations', event.target.value)}
741
+ />
742
+ </label>
743
+ </div>
726
744
  <div className={css.footer}>
727
745
  {state.failed ? <p className={css.failed}>保存失败</p> : null}
728
746
  <button type="button" className={css.discard} disabled={!state.dirty || state.saving} onClick={() => props.discard()}>{t('settings.discard')}</button>
package/src/client/api.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import {
7
- GENERATE_API, HISTORY_API, LIBRARY_API,
7
+ GENERATE_API, HISTORY_API, LIBRARY_API, TASK_API,
8
8
  type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry,
9
9
  type LibraryEntry, type LibrarySaveRequest, type LibraryUpdateRequest,
10
10
  } from '../protocol.ts'
@@ -20,59 +20,58 @@ export interface GenerateResponse {
20
20
  message?: string
21
21
  }
22
22
 
23
+ /** POST helper: the host API requires the JSON content type on every POST. */
24
+ function postJson(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
25
+ return fetch(path, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json' },
28
+ body: JSON.stringify(body),
29
+ ...(signal === undefined ? {} : { signal }),
30
+ })
31
+ }
32
+
23
33
  export class AudiogenApi {
24
- async generate(request: GenerateAudioRequest): Promise<GenerateResponse> {
25
- const response = await fetch(GENERATE_API, {
26
- method: 'POST',
27
- headers: { 'content-type': 'application/json' },
28
- body: JSON.stringify(request),
29
- })
34
+ async generate(request: GenerateAudioRequest, signal?: AbortSignal): Promise<GenerateResponse> {
35
+ const response = await postJson(GENERATE_API, { ...request, taskId: request.taskId }, signal)
30
36
  const body = await response.json() as GenerateResponse
31
37
  return body
32
38
  }
33
39
 
40
+ /** 取消进行中的任务:宿主侧中断全部在途上游调用,剩余模型跳过。 */
41
+ async cancelTask(taskId: string): Promise<void> {
42
+ await postJson(TASK_API.cancel, { taskId }).catch(() => { /* best-effort */ })
43
+ }
44
+
34
45
  async history(): Promise<HistoryEntry[]> {
35
- const response = await fetch(HISTORY_API.list, { method: 'POST' })
46
+ const response = await postJson(HISTORY_API.list, {})
36
47
  const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
37
48
  return body.ok === true ? (body.history ?? []) : []
38
49
  }
39
50
 
40
51
  async clearHistory(): Promise<void> {
41
- await fetch(HISTORY_API.clear, { method: 'POST' })
52
+ await postJson(HISTORY_API.clear, {})
42
53
  }
43
54
 
44
55
  async libraryList(): Promise<LibraryEntry[]> {
45
- const response = await fetch(LIBRARY_API.list, { method: 'POST' })
56
+ const response = await postJson(LIBRARY_API.list, {})
46
57
  const body = await response.json() as { ok?: boolean; entries?: LibraryEntry[] }
47
58
  return body.ok === true ? (body.entries ?? []) : []
48
59
  }
49
60
 
50
61
  async librarySave(request: LibrarySaveRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
51
- const response = await fetch(LIBRARY_API.save, {
52
- method: 'POST',
53
- headers: { 'content-type': 'application/json' },
54
- body: JSON.stringify(request),
55
- })
62
+ const response = await postJson(LIBRARY_API.save, request)
56
63
  const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
57
64
  return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
58
65
  }
59
66
 
60
67
  async libraryUpdate(request: LibraryUpdateRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
61
- const response = await fetch(LIBRARY_API.update, {
62
- method: 'POST',
63
- headers: { 'content-type': 'application/json' },
64
- body: JSON.stringify(request),
65
- })
68
+ const response = await postJson(LIBRARY_API.update, request)
66
69
  const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
67
70
  return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
68
71
  }
69
72
 
70
73
  async libraryRemove(ids: string[]): Promise<{ ok: boolean }> {
71
- const response = await fetch(LIBRARY_API.remove, {
72
- method: 'POST',
73
- headers: { 'content-type': 'application/json' },
74
- body: JSON.stringify({ ids }),
75
- })
74
+ const response = await postJson(LIBRARY_API.remove, { ids })
76
75
  const body = await response.json() as { ok?: boolean }
77
76
  return { ok: body.ok === true }
78
77
  }
@@ -425,6 +425,116 @@
425
425
  background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 8%, transparent);
426
426
  }
427
427
 
428
+ /* ---------------------------------------------------------- model compare */
429
+
430
+ .compareBox {
431
+ display: flex;
432
+ flex-direction: column;
433
+ gap: 7px;
434
+ padding: 9px 11px;
435
+ border: 1px dashed var(--dsw-alias-brand-primary, #2563eb);
436
+ border-radius: 10px;
437
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 4%, var(--dsw-alias-bg-layer-1, #fff));
438
+ }
439
+
440
+ .compareChips {
441
+ display: flex;
442
+ flex-wrap: wrap;
443
+ gap: 6px;
444
+ }
445
+
446
+ .compareChip {
447
+ display: inline-flex;
448
+ align-items: center;
449
+ min-height: 27px;
450
+ padding: 0 11px;
451
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
452
+ border-radius: 999px;
453
+ background: var(--dsw-alias-bg-layer-1, #fff);
454
+ color: var(--dsw-alias-label-secondary, #6b7280);
455
+ font-family: inherit;
456
+ font-size: 12px;
457
+ cursor: pointer;
458
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
459
+ }
460
+
461
+ .compareChip:hover {
462
+ color: var(--dsw-alias-label-primary, #1f2328);
463
+ border-color: var(--dsw-alias-label-dimmed, #9ca3af);
464
+ }
465
+
466
+ .compareChip[data-active='true'] {
467
+ color: var(--dsw-alias-brand-primary, #2563eb);
468
+ border-color: var(--dsw-alias-brand-primary, #2563eb);
469
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 9%, transparent);
470
+ font-weight: 600;
471
+ }
472
+
473
+ .compareBoard {
474
+ display: flex;
475
+ flex-direction: column;
476
+ gap: 12px;
477
+ }
478
+
479
+ .compareGroup {
480
+ display: flex;
481
+ flex-direction: column;
482
+ gap: 8px;
483
+ padding: 11px;
484
+ border: 1px solid var(--dsw-alias-border-l1, #e5e7eb);
485
+ border-radius: 12px;
486
+ background: var(--dsw-alias-bg-layer-2, #f7f7f8);
487
+ }
488
+
489
+ .compareGroup[data-state='running'] {
490
+ border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 45%, var(--dsw-alias-border-l1, #e5e7eb));
491
+ }
492
+
493
+ .compareGroup[data-state='done'] {
494
+ border-color: color-mix(in srgb, #10b981 45%, var(--dsw-alias-border-l1, #e5e7eb));
495
+ }
496
+
497
+ .compareGroup[data-state='error'] {
498
+ border-color: color-mix(in srgb, var(--dsw-alias-label-error, #b91c1c) 45%, var(--dsw-alias-border-l1, #e5e7eb));
499
+ }
500
+
501
+ .compareGroupHead {
502
+ display: flex;
503
+ align-items: center;
504
+ justify-content: space-between;
505
+ gap: 10px;
506
+ }
507
+
508
+ .compareModelName {
509
+ overflow: hidden;
510
+ padding: 3px 10px;
511
+ border-radius: 999px;
512
+ background: var(--dsw-alias-bg-layer-1, #fff);
513
+ border: 1px solid var(--dsw-alias-border-l1, #e5e7eb);
514
+ color: var(--dsw-alias-label-primary, #1f2328);
515
+ font-size: 12px;
516
+ font-weight: 600;
517
+ text-overflow: ellipsis;
518
+ white-space: nowrap;
519
+ }
520
+
521
+ .compareState {
522
+ display: inline-flex;
523
+ align-items: center;
524
+ gap: 5px;
525
+ font-size: 11px;
526
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
527
+ white-space: nowrap;
528
+ }
529
+
530
+ .compareGroup[data-state='running'] .compareState { color: var(--dsw-alias-brand-primary, #2563eb); }
531
+ .compareGroup[data-state='done'] .compareState { color: #059669; }
532
+ .compareGroup[data-state='error'] .compareState { color: var(--dsw-alias-label-error, #b91c1c); }
533
+
534
+ .hint[data-error] {
535
+ color: var(--dsw-alias-label-error, #b91c1c);
536
+ }
537
+
428
538
  /* --------------------------------------------------------------- history */
429
539
 
430
540
  .historyCol {
@@ -805,3 +915,221 @@
805
915
  @media (prefers-reduced-motion: reduce) {
806
916
  .toast { animation-duration: 1ms; }
807
917
  }
918
+
919
+ /* 多模型对比 */
920
+ .modelCheckList {
921
+ max-height: 180px;
922
+ overflow-y: auto;
923
+ display: flex;
924
+ flex-direction: column;
925
+ gap: 4px;
926
+ padding: 8px;
927
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
928
+ border-radius: 8px;
929
+ background: var(--dsw-alias-bg-layer-3, #fff);
930
+ }
931
+
932
+ .resultGroups {
933
+ display: flex;
934
+ flex-direction: column;
935
+ gap: 16px;
936
+ }
937
+
938
+ .resultGroup {
939
+ display: flex;
940
+ flex-direction: column;
941
+ gap: 8px;
942
+ }
943
+
944
+ .resultGroupHead {
945
+ display: flex;
946
+ align-items: center;
947
+ gap: 8px;
948
+ flex-wrap: wrap;
949
+ }
950
+
951
+ .resultGroupChip {
952
+ font-size: 12px;
953
+ font-weight: 600;
954
+ padding: 3px 10px;
955
+ border-radius: 999px;
956
+ background: var(--dsw-alias-bg-hover, #f3f4f6);
957
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
958
+ color: var(--dsw-alias-label-primary, #1f2328);
959
+ }
960
+
961
+ .resultGroupError {
962
+ font-size: 12px;
963
+ color: #dc2626;
964
+ }
965
+
966
+ .resultGroupCount {
967
+ font-size: 11px;
968
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
969
+ }
970
+
971
+ /* 每模型参数覆盖矩阵 */
972
+ .overrideTable {
973
+ display: flex;
974
+ flex-direction: column;
975
+ gap: 6px;
976
+ }
977
+
978
+ .overrideRow {
979
+ display: flex;
980
+ gap: 6px;
981
+ align-items: center;
982
+ }
983
+
984
+ .overrideCell {
985
+ flex: 1;
986
+ min-width: 0;
987
+ font-size: 12px;
988
+ font-weight: 500;
989
+ color: var(--dsw-alias-label-secondary, #6b7280);
990
+ overflow: hidden;
991
+ text-overflow: ellipsis;
992
+ white-space: nowrap;
993
+ }
994
+
995
+ .overrideCell .input {
996
+ min-height: 30px;
997
+ padding: 5px 8px;
998
+ }
999
+
1000
+ .overrideCellHead {
1001
+ font-weight: 700;
1002
+ color: var(--dsw-alias-label-primary, #1f2328);
1003
+ white-space: normal;
1004
+ }
1005
+
1006
+ /* 任务列表 */
1007
+ .taskList {
1008
+ display: flex;
1009
+ flex-direction: column;
1010
+ gap: 14px;
1011
+ }
1012
+
1013
+ .taskCard {
1014
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
1015
+ border-radius: 10px;
1016
+ padding: 10px 12px;
1017
+ display: flex;
1018
+ flex-direction: column;
1019
+ gap: 8px;
1020
+ background: var(--dsw-alias-bg-layer-2, #fafafa);
1021
+ }
1022
+
1023
+ .taskCard[data-state='failed'] {
1024
+ border-color: #dc2626;
1025
+ }
1026
+
1027
+ .taskCard[data-state='cancelled'] {
1028
+ opacity: 0.75;
1029
+ }
1030
+
1031
+ .taskHead {
1032
+ display: flex;
1033
+ align-items: center;
1034
+ gap: 8px;
1035
+ flex-wrap: wrap;
1036
+ }
1037
+
1038
+ .taskLabel {
1039
+ font-size: 13px;
1040
+ font-weight: 600;
1041
+ color: var(--dsw-alias-label-primary, #1f2328);
1042
+ overflow: hidden;
1043
+ text-overflow: ellipsis;
1044
+ white-space: nowrap;
1045
+ max-width: 200px;
1046
+ }
1047
+
1048
+ .taskStatus {
1049
+ font-size: 12px;
1050
+ color: var(--dsw-alias-label-secondary, #6b7280);
1051
+ }
1052
+
1053
+ .taskStatus[data-state='done'] {
1054
+ color: #16a34a;
1055
+ }
1056
+
1057
+ .taskStatus[data-state='failed'] {
1058
+ color: #dc2626;
1059
+ }
1060
+
1061
+ .taskActions {
1062
+ margin-left: auto;
1063
+ display: flex;
1064
+ gap: 6px;
1065
+ }
1066
+
1067
+ /* 历史记录分类 */
1068
+ .historyTabs {
1069
+ display: flex;
1070
+ flex-wrap: wrap;
1071
+ gap: 6px;
1072
+ margin-bottom: 10px;
1073
+ }
1074
+
1075
+ .historyTab {
1076
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
1077
+ border-radius: 999px;
1078
+ padding: 3px 10px;
1079
+ font-size: 12px;
1080
+ cursor: pointer;
1081
+ background: var(--dsw-alias-bg-layer-3, #fff);
1082
+ color: var(--dsw-alias-label-secondary, #6b7280);
1083
+ }
1084
+
1085
+ .historyTab[data-active='true'] {
1086
+ background: var(--dsw-alias-bg-hover, #f3f4f6);
1087
+ color: var(--dsw-alias-label-primary, #1f2328);
1088
+ font-weight: 600;
1089
+ }
1090
+
1091
+ .historyTabCount {
1092
+ margin-left: 4px;
1093
+ font-size: 11px;
1094
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
1095
+ }
1096
+
1097
+ /* 对比任务卡 */
1098
+ .historyCompareSummary {
1099
+ display: flex;
1100
+ align-items: center;
1101
+ gap: 8px;
1102
+ cursor: pointer;
1103
+ flex-wrap: wrap;
1104
+ }
1105
+
1106
+ .historyCompareBadge {
1107
+ font-size: 11px;
1108
+ font-weight: 600;
1109
+ color: #7c3aed;
1110
+ border: 1px solid #ddd6fe;
1111
+ border-radius: 999px;
1112
+ padding: 1px 8px;
1113
+ background: #f5f3ff;
1114
+ white-space: nowrap;
1115
+ }
1116
+
1117
+ .historyModelRow {
1118
+ margin-top: 8px;
1119
+ padding-top: 8px;
1120
+ border-top: 1px dashed var(--dsw-alias-border-l1, #e5e7eb);
1121
+ display: flex;
1122
+ flex-direction: column;
1123
+ gap: 4px;
1124
+ }
1125
+
1126
+ .overrideOnly {
1127
+ font-size: 10px;
1128
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
1129
+ font-weight: 500;
1130
+ }
1131
+
1132
+ .overrideDash {
1133
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
1134
+ padding: 0 6px;
1135
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 厂商感知的字段规格表:按(渠道 preset × 模式)声明面板该显示哪些参数,
3
+ * 以及每个参数适用于哪些渠道。引擎不变——引擎本来就按渠道消费字段;
4
+ * 这里只负责「该显示什么、标签/枚举/校验按渠道」。
5
+ */
6
+
7
+ import type { AudioMode } from '../protocol.ts'
8
+
9
+ /** 与面板表单 state 对应的一组字段键。 */
10
+ export type FieldKey =
11
+ | 'duration'
12
+ | 'format'
13
+ | 'lyrics'
14
+ | 'instrumental'
15
+ | 'sampleRate'
16
+ | 'bitrate'
17
+ | 'audioChannel'
18
+ | 'voice'
19
+ | 'speed'
20
+ | 'emotion'
21
+ | 'vol'
22
+ | 'pitch'
23
+ | 'toneText'
24
+ | 'subtitle'
25
+ | 'loop'
26
+ | 'promptInfluence'
27
+ | 'seed'
28
+ | 'steps'
29
+ | 'cfgScale'
30
+
31
+ export interface FieldSpec {
32
+ key: FieldKey
33
+ label: string
34
+ type: 'text' | 'number' | 'select' | 'checkbox'
35
+ options?: string[]
36
+ placeholder?: string
37
+ min?: number
38
+ max?: number
39
+ step?: number
40
+ /** 官方字段名/说明(tooltip)。 */
41
+ hint?: string
42
+ /** 放在折叠「高级参数」里。 */
43
+ advanced?: boolean
44
+ /** 适用的渠道 preset;undefined 表示通用。 */
45
+ presets?: string[]
46
+ }
47
+
48
+ const PRESET_MINIMAX = 'minimax'
49
+ const PRESET_ELEVENLABS = 'elevenlabs'
50
+ const PRESET_STABILITY = 'stability-audio'
51
+
52
+ /** 各 preset 支持的音乐输出格式(交集用于全局字段)。 */
53
+ const MUSIC_FORMATS: Record<string, string[]> = {
54
+ [PRESET_MINIMAX]: ['mp3', 'wav', 'pcm'],
55
+ [PRESET_ELEVENLABS]: ['mp3', 'wav'],
56
+ [PRESET_STABILITY]: ['mp3', 'wav'],
57
+ }
58
+
59
+ const MUSIC_KEYS: FieldKey[] = ['duration', 'format', 'lyrics', 'instrumental', 'sampleRate', 'bitrate']
60
+ const TTS_KEYS: FieldKey[] = ['voice', 'speed', 'format', 'emotion', 'vol', 'pitch', 'toneText', 'sampleRate', 'bitrate', 'audioChannel', 'subtitle']
61
+ const SFX_KEYS: FieldKey[] = ['duration', 'format', 'loop', 'promptInfluence', 'seed', 'steps', 'cfgScale']
62
+
63
+ export function presetSupports(preset: string, key: FieldKey, mode: AudioMode): boolean {
64
+ const p = preset.toLowerCase()
65
+ const K = MUSIC_KEYS.includes(key) ? MUSIC_KEYS : TTS_KEYS.includes(key) ? TTS_KEYS : SFX_KEYS
66
+ if (!K.includes(key)) return true
67
+ switch (key) {
68
+ case 'lyrics':
69
+ case 'instrumental':
70
+ return p === PRESET_MINIMAX || p === PRESET_ELEVENLABS
71
+ case 'sampleRate':
72
+ case 'bitrate':
73
+ case 'audioChannel':
74
+ return p === PRESET_MINIMAX
75
+ case 'emotion':
76
+ case 'vol':
77
+ case 'pitch':
78
+ case 'toneText':
79
+ case 'subtitle':
80
+ return p === PRESET_MINIMAX
81
+ case 'loop':
82
+ case 'promptInfluence':
83
+ return p === PRESET_ELEVENLABS
84
+ case 'seed':
85
+ case 'steps':
86
+ case 'cfgScale':
87
+ return p === PRESET_STABILITY
88
+ default:
89
+ return true
90
+ }
91
+ }
92
+
93
+ const SPECS: Record<FieldKey, Omit<FieldSpec, 'key' | 'presets'>> = {
94
+ duration: { label: '时长(秒)', type: 'number', min: 1, max: 120, placeholder: '30', hint: 'duration;MiniMax 音乐 ≤190、ElevenLabs 音乐 3-600(转 ms)、Stability 按模型 190/380' },
95
+ format: { label: '输出格式', type: 'select', options: ['mp3', 'wav', 'pcm', 'flac', 'ogg'], hint: 'format / output_format / response_format' },
96
+ lyrics: { label: '歌词(纯音乐模式可留空;多段用空行分隔)', type: 'text', placeholder: '第一段歌词…\n\n第二段歌词…', hint: 'MiniMax lyrics / ElevenLabs lyrics_text' },
97
+ instrumental: { label: '纯音乐(无歌词/人声)', type: 'checkbox', hint: 'MiniMax is_instrumental / ElevenLabs force_instrumental' },
98
+ sampleRate: { label: '采样率', type: 'select', options: ['16000', '24000', '32000', '44100'], placeholder: '默认(44100)', hint: 'audio_setting.sample_rate:16000-44100' },
99
+ bitrate: { label: '码率 bps', type: 'select', options: ['32000', '64000', '128000', '256000'], placeholder: '默认(256000)', hint: 'audio_setting.bitrate:32000-256000' },
100
+ audioChannel: { label: '声道', type: 'select', options: ['1', '2'], placeholder: '默认(1)', hint: 'audio_setting.channel(TTS)' },
101
+ voice: { label: '音色', type: 'text', placeholder: '自定义音色', hint: 'voice_id(MiniMax 必填)/ voice(ElevenLabs)' },
102
+ speed: { label: '语速', type: 'number', min: 0.5, max: 2, step: 0.1, placeholder: '1.0', hint: 'speed(MiniMax 0.5-2)' },
103
+ emotion: { label: '情绪 emotion', type: 'text', placeholder: 'happy / sad / angry / nervous…', hint: 'voice_setting.emotion', advanced: true },
104
+ vol: { label: '音量 vol (0-10)', type: 'number', min: 0, max: 10, step: 0.5, placeholder: '1', hint: 'voice_setting.vol', advanced: true },
105
+ pitch: { label: '音调 pitch (-12~12)', type: 'number', min: -12, max: 12, placeholder: '0', hint: 'voice_setting.pitch', advanced: true },
106
+ toneText: { label: '发音词典(每行一条:"文字/读音")', type: 'text', placeholder: '处理/(chu3)(li3)\n危险/dangerous', hint: 'pronunciation_dict.tone', advanced: true },
107
+ subtitle: { label: '生成字幕 subtitle_enable', type: 'checkbox', hint: 'subtitle_enable', advanced: true },
108
+ loop: { label: '循环音效 loop(无缝循环)', type: 'checkbox', hint: 'ElevenLabs loop(仅 eleven_text_to_sound_v2)' },
109
+ promptInfluence: { label: '提示词影响度 prompt_influence (0-1)', type: 'number', min: 0, max: 1, step: 0.1, placeholder: '0.3', hint: 'prompt_influence:越高越贴提示词' },
110
+ seed: { label: 'seed', type: 'number', min: 0, max: 4294967294, placeholder: '默认(随机)', hint: 'Stable Audio seed(同参数可复现)' },
111
+ steps: { label: 'steps', type: 'number', min: 4, max: 100, placeholder: '默认', hint: 'Stable Audio 采样步数(2: 30-100;2.5/3: 4-8)' },
112
+ cfgScale: { label: 'cfg_scale', type: 'number', min: 1, max: 25, placeholder: '默认', hint: 'Stable Audio 提示词遵循度(2 默认 7,2.5/3 默认 1)' },
113
+ }
114
+
115
+ function specOf(key: FieldKey, presets?: string[]): FieldSpec {
116
+ return { key, ...SPECS[key], ...(presets === undefined ? {} : { presets }) }
117
+ }
118
+
119
+ /** 当前模式 + 所选模型集合(渠道集合)对应的「全局字段」清单。 */
120
+ export function globalFieldSpecs(mode: AudioMode, presets: string[]): FieldSpec[] {
121
+ const list: FieldSpec[] = []
122
+ const all = (key: FieldKey, keys: FieldKey[]): boolean => keys.includes(key) && presets.every(preset => presetSupports(preset, key, mode))
123
+ if (mode === 'tts') {
124
+ list.push(specOf('voice'), specOf('speed'))
125
+ list.push({ ...specOf('format'), options: ['mp3', 'wav', 'flac', 'ogg', 'pcm'] })
126
+ const advanced = (['emotion', 'vol', 'pitch', 'toneText', 'sampleRate', 'bitrate', 'audioChannel', 'subtitle'] as FieldKey[]).filter(key => all(key, TTS_KEYS) && presets.length > 0)
127
+ for (const key of advanced) list.push(specOf(key))
128
+ } else if (mode === 'music') {
129
+ list.push(specOf('duration'))
130
+ // 格式选项取所有选中渠道支持的交集(无交集则回退 mp3/wav)
131
+ const supported: string[][] = presets.length === 0 ? [['mp3', 'wav', 'pcm']] : presets.map(preset => MUSIC_FORMATS[preset.toLowerCase()] ?? ['mp3', 'wav'])
132
+ const intersect: string[] = supported.reduce<string[]>((acc, cur) => acc.filter(item => cur.includes(item)), supported[0] ?? ['mp3', 'wav'])
133
+ list.push({ ...specOf('format'), options: intersect.length > 0 ? intersect : ['mp3', 'wav'] })
134
+ if (all('lyrics', MUSIC_KEYS) && presets.length > 0) list.push(specOf('lyrics'))
135
+ if (all('instrumental', MUSIC_KEYS) && presets.length > 0) list.push(specOf('instrumental'))
136
+ if (all('sampleRate', MUSIC_KEYS) && presets.length > 0) list.push(specOf('sampleRate'))
137
+ if (all('bitrate', MUSIC_KEYS) && presets.length > 0) list.push(specOf('bitrate'))
138
+ } else if (mode === 'sfx') {
139
+ list.push(specOf('duration'))
140
+ list.push({ ...specOf('format'), options: ['mp3', 'wav', 'pcm'] })
141
+ if (all('loop', SFX_KEYS) && presets.length > 0) list.push(specOf('loop'))
142
+ if (all('promptInfluence', SFX_KEYS) && presets.length > 0) list.push(specOf('promptInfluence'))
143
+ if (all('seed', SFX_KEYS) && presets.length > 0) list.push(specOf('seed'), specOf('steps'), specOf('cfgScale'))
144
+ }
145
+ return list
146
+ }
147
+
148
+ /** 「每模型参数覆盖」矩阵的字段全集(含适用渠道标注)。 */
149
+ export function overrideRowSpecs(mode: AudioMode): Array<FieldSpec & { presets: string[] }> {
150
+ const rows: Array<FieldSpec & { presets: string[] }> = []
151
+ const presets: Array<[FieldKey, string[]]> = [
152
+ ['format', [PRESET_MINIMAX, PRESET_ELEVENLABS, PRESET_STABILITY]],
153
+ ['duration', [PRESET_MINIMAX, PRESET_ELEVENLABS, PRESET_STABILITY]],
154
+ ['voice', [PRESET_MINIMAX, PRESET_ELEVENLABS]],
155
+ ['speed', [PRESET_MINIMAX, PRESET_ELEVENLABS]],
156
+ ['lyrics', [PRESET_MINIMAX, PRESET_ELEVENLABS]],
157
+ ['instrumental', [PRESET_MINIMAX, PRESET_ELEVENLABS]],
158
+ ['sampleRate', [PRESET_MINIMAX]],
159
+ ['bitrate', [PRESET_MINIMAX]],
160
+ ['audioChannel', [PRESET_MINIMAX]],
161
+ ['emotion', [PRESET_MINIMAX]],
162
+ ['vol', [PRESET_MINIMAX]],
163
+ ['pitch', [PRESET_MINIMAX]],
164
+ ['toneText', [PRESET_MINIMAX]],
165
+ ['subtitle', [PRESET_MINIMAX]],
166
+ ['loop', [PRESET_ELEVENLABS]],
167
+ ['promptInfluence', [PRESET_ELEVENLABS]],
168
+ ['seed', [PRESET_STABILITY]],
169
+ ['steps', [PRESET_STABILITY]],
170
+ ['cfgScale', [PRESET_STABILITY]],
171
+ ]
172
+ for (const [key, applicable] of presets) {
173
+ const spec = specOf(key, applicable)
174
+ rows.push({ ...spec, presets: applicable })
175
+ }
176
+ return rows
177
+ }
178
+
179
+ /** 渠道 preset 的展示名。 */
180
+ export function presetLabel(preset: string): string {
181
+ if (preset === PRESET_MINIMAX) return 'MiniMax'
182
+ if (preset === PRESET_ELEVENLABS) return 'ElevenLabs'
183
+ if (preset === PRESET_STABILITY) return 'Stability'
184
+ if (preset === 'openai-tts') return 'OpenAI'
185
+ return '自定义'
186
+ }
@@ -319,7 +319,12 @@ export function LibraryView(props: {
319
319
  </div>
320
320
 
321
321
  {loading ? <p className={css.stateNote}>加载中…</p> : null}
322
- {error !== null ? <p className={css.stateNote} data-error>{error}</p> : null}
322
+ {error !== null ? (
323
+ <div className={css.stateNote} data-error>
324
+ <p>{error}</p>
325
+ <p>若提示 not found / 404:插件宿主尚未加载新代码,请重启 `dsh web` 后在浏览器强制刷新(Cmd+Shift+R)。</p>
326
+ </div>
327
+ ) : null}
323
328
  {!loading && error === null && filtered.length === 0 ? (
324
329
  <div className={css.empty}>
325
330
  <span className={css.emptyIcon}>🎧</span>