dsh-audiogen 0.4.20 → 0.4.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/README.zh-CN.md +4 -3
- package/lib/client.js +942 -404
- package/lib/client.js.map +1 -1
- package/lib/index.js +1292 -47
- package/package.json +1 -1
- package/skills/voice-cast/SKILL.md +151 -0
- package/skills/voice-management/SKILL.md +37 -2
- package/src/agent-audio-tools.ts +261 -13
- package/src/client/AudioGenPanel.tsx +1 -1
- package/src/client/api.ts +55 -0
- package/src/client/audio-panel.module.css +67 -0
- package/src/client/field-specs.ts +4 -2
- package/src/client/icons.tsx +13 -0
- package/src/client/studio-view.tsx +275 -34
- package/src/client/voices-view.tsx +107 -16
- package/src/client/voices.module.css +165 -0
- package/src/index.ts +16 -1
- package/src/protocol.ts +62 -3
- package/src/routes.ts +157 -27
- package/src/voice-cast.ts +712 -0
- package/src/voice-manager.ts +49 -3
- package/src/voice-recommend.ts +350 -0
package/src/client/api.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry,
|
|
9
9
|
type LibraryEntry, type LibrarySaveRequest, type LibraryUpdateRequest,
|
|
10
10
|
type VoiceEntry, type VoicesListRequest, type VoicesDeleteRequest,
|
|
11
|
+
type VoicesRecommendRequest, type VoiceRecommendation, type VoiceRecommendRecord,
|
|
11
12
|
} from '../protocol.ts'
|
|
12
13
|
|
|
13
14
|
export interface GenerateResponse {
|
|
@@ -127,10 +128,64 @@ export class AudiogenApi {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/** 读取最近 N 条 AI 音色推荐记录(面板与 Agent 共用)。 */
|
|
132
|
+
async voiceRecommendHistory(limit = 20): Promise<{ ok: boolean; entries?: VoiceRecommendRecord[]; message?: string }> {
|
|
133
|
+
const response = await postJson(VOICES_API.recommendHistory.list, { limit })
|
|
134
|
+
const body = await response.json() as { ok?: boolean; entries?: VoiceRecommendRecord[]; message?: string }
|
|
135
|
+
return { ok: body.ok === true, ...(body.entries === undefined ? {} : { entries: body.entries }), ...(body.message === undefined ? {} : { message: body.message }) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 删除一条 AI 音色推荐记录。 */
|
|
139
|
+
async voiceRecommendHistoryRemove(id: string): Promise<{ ok: boolean; message?: string }> {
|
|
140
|
+
const response = await postJson(VOICES_API.recommendHistory.remove, { id })
|
|
141
|
+
const body = await response.json() as { ok?: boolean; message?: string }
|
|
142
|
+
return { ok: body.ok === true, ...(body.message === undefined ? {} : { message: body.message }) }
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
/** 删除厂商音色(不可逆;confirm=true 必须)。 */
|
|
131
146
|
async voiceDelete(request: VoicesDeleteRequest): Promise<{ ok: boolean; message?: string; code?: string }> {
|
|
132
147
|
const response = await postJson(VOICES_API.delete, request)
|
|
133
148
|
const body = await response.json() as { ok?: boolean; message?: string; code?: string }
|
|
134
149
|
return { ok: body.ok === true, ...(body.message === undefined ? {} : { message: body.message }), ...(body.code === undefined ? {} : { code: body.code }) }
|
|
135
150
|
}
|
|
151
|
+
|
|
152
|
+
/** 按需求描述(prompt)用 Agent 默认模型推荐音色(MiniMax / ElevenLabs)。 */
|
|
153
|
+
async voiceRecommend(request: VoicesRecommendRequest): Promise<{
|
|
154
|
+
ok: boolean
|
|
155
|
+
vendor?: string
|
|
156
|
+
channel?: string
|
|
157
|
+
requirement?: string
|
|
158
|
+
candidate_count?: number
|
|
159
|
+
top_k?: number
|
|
160
|
+
recommendations?: VoiceRecommendation[]
|
|
161
|
+
note?: string
|
|
162
|
+
code?: string
|
|
163
|
+
message?: string
|
|
164
|
+
}> {
|
|
165
|
+
const response = await postJson(VOICES_API.recommend, request)
|
|
166
|
+
const body = await response.json() as {
|
|
167
|
+
ok?: boolean
|
|
168
|
+
vendor?: string
|
|
169
|
+
channel?: string
|
|
170
|
+
requirement?: string
|
|
171
|
+
candidate_count?: number
|
|
172
|
+
top_k?: number
|
|
173
|
+
recommendations?: VoiceRecommendation[]
|
|
174
|
+
note?: string
|
|
175
|
+
code?: string
|
|
176
|
+
message?: string
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
ok: body.ok === true,
|
|
180
|
+
...(body.vendor === undefined ? {} : { vendor: body.vendor }),
|
|
181
|
+
...(body.channel === undefined ? {} : { channel: body.channel }),
|
|
182
|
+
...(body.requirement === undefined ? {} : { requirement: body.requirement }),
|
|
183
|
+
...(body.candidate_count === undefined ? {} : { candidate_count: body.candidate_count }),
|
|
184
|
+
...(body.top_k === undefined ? {} : { top_k: body.top_k }),
|
|
185
|
+
...(body.recommendations === undefined ? {} : { recommendations: body.recommendations }),
|
|
186
|
+
...(body.note === undefined ? {} : { note: body.note }),
|
|
187
|
+
...(body.code === undefined ? {} : { code: body.code }),
|
|
188
|
+
...(body.message === undefined ? {} : { message: body.message }),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
136
191
|
}
|
|
@@ -1360,3 +1360,70 @@
|
|
|
1360
1360
|
justify-content: flex-end;
|
|
1361
1361
|
margin-top: -4px;
|
|
1362
1362
|
}
|
|
1363
|
+
|
|
1364
|
+
/* ------------------------------------------------ 音色推荐(studio 模式) */
|
|
1365
|
+
.recVoiceRow {
|
|
1366
|
+
display: flex;
|
|
1367
|
+
align-items: flex-start;
|
|
1368
|
+
gap: 8px;
|
|
1369
|
+
padding: 8px 0;
|
|
1370
|
+
border-top: 1px solid var(--dsw-alias-border-l2, #f3f4f6);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
.recVoiceRow:first-of-type {
|
|
1374
|
+
border-top: none;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
.recRank {
|
|
1378
|
+
display: inline-flex;
|
|
1379
|
+
align-items: center;
|
|
1380
|
+
justify-content: center;
|
|
1381
|
+
width: 20px;
|
|
1382
|
+
height: 20px;
|
|
1383
|
+
border-radius: 999px;
|
|
1384
|
+
background: var(--dsw-accent, #2563eb);
|
|
1385
|
+
color: #fff;
|
|
1386
|
+
font-size: 11px;
|
|
1387
|
+
font-weight: 600;
|
|
1388
|
+
flex: none;
|
|
1389
|
+
margin-top: 2px;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
.recVoiceBody {
|
|
1393
|
+
flex: 1;
|
|
1394
|
+
min-width: 0;
|
|
1395
|
+
display: flex;
|
|
1396
|
+
flex-direction: column;
|
|
1397
|
+
gap: 4px;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
.recVoiceHead {
|
|
1401
|
+
display: flex;
|
|
1402
|
+
align-items: center;
|
|
1403
|
+
gap: 6px;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
.recVoiceName {
|
|
1407
|
+
font-size: 13px;
|
|
1408
|
+
color: var(--dsw-alias-fg, #111827);
|
|
1409
|
+
overflow: hidden;
|
|
1410
|
+
text-overflow: ellipsis;
|
|
1411
|
+
white-space: nowrap;
|
|
1412
|
+
max-width: 260px;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
.recReason {
|
|
1416
|
+
margin: 0;
|
|
1417
|
+
font-size: 12.5px;
|
|
1418
|
+
line-height: 1.5;
|
|
1419
|
+
color: var(--dsw-accent, #2563eb);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
.recDescription {
|
|
1423
|
+
margin: 0;
|
|
1424
|
+
font-size: 12px;
|
|
1425
|
+
line-height: 1.5;
|
|
1426
|
+
color: var(--dsw-alias-fg-l2, #4b5563);
|
|
1427
|
+
white-space: pre-wrap;
|
|
1428
|
+
word-break: break-word;
|
|
1429
|
+
}
|
|
@@ -117,7 +117,8 @@ function specOf(key: FieldKey, presets?: string[]): FieldSpec {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/** 当前模式 + 所选模型集合(渠道集合)对应的「全局字段」清单。 */
|
|
120
|
-
export function globalFieldSpecs(mode: AudioMode, presets: string[]): FieldSpec[] {
|
|
120
|
+
export function globalFieldSpecs(mode: AudioMode | 'voice_recommend', presets: string[]): FieldSpec[] {
|
|
121
|
+
if (mode === 'voice_recommend') return []
|
|
121
122
|
const list: FieldSpec[] = []
|
|
122
123
|
const all = (key: FieldKey, keys: FieldKey[]): boolean => keys.includes(key) && presets.every(preset => presetSupports(preset, key, mode))
|
|
123
124
|
if (mode === 'tts') {
|
|
@@ -146,7 +147,8 @@ export function globalFieldSpecs(mode: AudioMode, presets: string[]): FieldSpec[
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
/** 「每模型参数覆盖」矩阵的字段全集(含适用渠道标注)。 */
|
|
149
|
-
export function overrideRowSpecs(mode: AudioMode): Array<FieldSpec & { presets: string[] }> {
|
|
150
|
+
export function overrideRowSpecs(mode: AudioMode | 'voice_recommend'): Array<FieldSpec & { presets: string[] }> {
|
|
151
|
+
if (mode === 'voice_recommend') return []
|
|
150
152
|
const rows: Array<FieldSpec & { presets: string[] }> = []
|
|
151
153
|
const presets: Array<[FieldKey, string[]]> = [
|
|
152
154
|
['format', [PRESET_MINIMAX, PRESET_ELEVENLABS, PRESET_STABILITY]],
|
package/src/client/icons.tsx
CHANGED
|
@@ -168,3 +168,16 @@ export function ListIcon(): JSX.Element {
|
|
|
168
168
|
</Svg>
|
|
169
169
|
)
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
export function SparkIcon(): JSX.Element {
|
|
173
|
+
return (
|
|
174
|
+
<Svg viewBox="0 0 16 16">
|
|
175
|
+
<path
|
|
176
|
+
d="M6.5 2.4l1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z"
|
|
177
|
+
fill="currentColor"
|
|
178
|
+
stroke="none"
|
|
179
|
+
/>
|
|
180
|
+
<path d="M11.6 9.2l.6 1.8 1.8.6-1.8.6-.6 1.8-.6-1.8-1.8-.6 1.8-.6.6-1.8Z" fill="currentColor" stroke="none" />
|
|
181
|
+
</Svg>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -15,6 +15,7 @@ import { tt } from './helpers.ts'
|
|
|
15
15
|
import {
|
|
16
16
|
HISTORY_API,
|
|
17
17
|
type AudioMode, type GeneratedAudio, type GenerateAudioRequest, type HistoryAudioRef, type HistoryEntry, type LibraryEntry,
|
|
18
|
+
type VoiceRecommendation, type VoiceRecommendRecord,
|
|
18
19
|
} from '../protocol.ts'
|
|
19
20
|
import type { ModelOption } from './settings-scope.ts'
|
|
20
21
|
import { AudioPlayer } from './audio-player.tsx'
|
|
@@ -104,15 +105,19 @@ function overrideSpread(override: Record<string, string>): Partial<GenerateAudio
|
|
|
104
105
|
/** 历史记录分组 tab(全部 + 按模式)。 */
|
|
105
106
|
type HistoryTab = 'all' | AudioMode
|
|
106
107
|
|
|
108
|
+
/** 生成模式 + 音色推荐(prompt → 音色,结果与记录在右侧)。 */
|
|
109
|
+
type StudioMode = AudioMode | 'voice_recommend'
|
|
110
|
+
|
|
107
111
|
function taskIdOf(entry: HistoryEntry): string {
|
|
108
112
|
const params = entry.params as Record<string, unknown> | undefined
|
|
109
113
|
return typeof params?.taskId === 'string' && params.taskId !== '' ? params.taskId : ''
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
function modeLabelOf(mode:
|
|
116
|
+
function modeLabelOf(mode: StudioMode): string {
|
|
113
117
|
if (mode === 'tts') return tt('mode.tts')
|
|
114
118
|
if (mode === 'music') return tt('mode.music')
|
|
115
119
|
if (mode === 'sfx') return tt('mode.sfx')
|
|
120
|
+
if (mode === 'voice_recommend') return '音色推荐'
|
|
116
121
|
return tt('mode.voiceDesign')
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -245,9 +250,9 @@ export function StudioView(props: {
|
|
|
245
250
|
return channel.apiUrl.trim() !== '' && keyHeld && (channel.models.length > 0 || channel.preset === 'minimax')
|
|
246
251
|
})
|
|
247
252
|
|
|
248
|
-
const [mode, setMode] = useState<
|
|
249
|
-
/** 每个模式独立的输入内容(TTS 文本 / 音乐·音效提示词 /
|
|
250
|
-
const [promptByMode, setPromptByMode] = useState<Record<
|
|
253
|
+
const [mode, setMode] = useState<StudioMode>('tts')
|
|
254
|
+
/** 每个模式独立的输入内容(TTS 文本 / 音乐·音效提示词 / 音色描述 / 音色需求),切模式互不干扰。 */
|
|
255
|
+
const [promptByMode, setPromptByMode] = useState<Record<StudioMode, string>>({ tts: '', music: '', sfx: '', voice_design: '', voice_recommend: '' })
|
|
251
256
|
const prompt = promptByMode[mode] ?? ''
|
|
252
257
|
const setPrompt = (next: string): void => {
|
|
253
258
|
const target = mode
|
|
@@ -296,13 +301,13 @@ export function StudioView(props: {
|
|
|
296
301
|
const [historyTab, setHistoryTab] = useState<HistoryTab>('all')
|
|
297
302
|
// 模型对比
|
|
298
303
|
/** 对比模式与对比模型选择按模式独立保存:恢复对比任务或切换模式时不会串到其他模块。 */
|
|
299
|
-
const [compareModeByMode, setCompareModeByMode] = useState<Record<
|
|
304
|
+
const [compareModeByMode, setCompareModeByMode] = useState<Record<StudioMode, boolean>>({ tts: false, music: false, sfx: false, voice_design: false, voice_recommend: false })
|
|
300
305
|
const compareMode = compareModeByMode[mode] ?? false
|
|
301
306
|
const setCompareMode = (next: boolean): void => {
|
|
302
307
|
const target = mode
|
|
303
308
|
setCompareModeByMode(current => (current[target] === next ? current : { ...current, [target]: next }))
|
|
304
309
|
}
|
|
305
|
-
const [compareModelsByMode, setCompareModelsByMode] = useState<Record<
|
|
310
|
+
const [compareModelsByMode, setCompareModelsByMode] = useState<Record<StudioMode, string[]>>({ tts: [], music: [], sfx: [], voice_design: [], voice_recommend: [] })
|
|
306
311
|
const compareModels = compareModelsByMode[mode] ?? []
|
|
307
312
|
const setCompareModels = (next: string[] | ((current: string[]) => string[])): void => {
|
|
308
313
|
const target = mode
|
|
@@ -330,8 +335,84 @@ export function StudioView(props: {
|
|
|
330
335
|
}
|
|
331
336
|
}, [channels, modelOptions.defaultChannelId, designChannelId])
|
|
332
337
|
|
|
338
|
+
// ---- 音色推荐(左侧模式「音色推荐」:prompt → 候选音色,结果与记录在右侧) ----
|
|
339
|
+
const [recommendChannelId, setRecommendChannelId] = useState('')
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (channels.length === 0) return
|
|
342
|
+
if (recommendChannelId === '' || !channels.some(candidate => candidate.id === recommendChannelId)) {
|
|
343
|
+
setRecommendChannelId(modelOptions.defaultChannelId ?? channels[0]!.id)
|
|
344
|
+
}
|
|
345
|
+
}, [channels, modelOptions.defaultChannelId, recommendChannelId])
|
|
346
|
+
const [recommendTopK, setRecommendTopK] = useState(5)
|
|
347
|
+
const [recommending, setRecommending] = useState(false)
|
|
348
|
+
const [recommendError, setRecommendError] = useState<string | null>(null)
|
|
349
|
+
const [recommendResult, setRecommendResult] = useState<{
|
|
350
|
+
channelId: string
|
|
351
|
+
channel: string
|
|
352
|
+
requirement: string
|
|
353
|
+
candidateCount: number
|
|
354
|
+
voices: VoiceRecommendation[]
|
|
355
|
+
} | null>(null)
|
|
356
|
+
/** 右侧推荐记录(与音色管理页、Agent action=recommend 共用同一宿主存储)。 */
|
|
357
|
+
const [recommendRecords, setRecommendRecords] = useState<VoiceRecommendRecord[]>([])
|
|
358
|
+
const loadRecommendRecords = (): void => {
|
|
359
|
+
void api.voiceRecommendHistory(30)
|
|
360
|
+
.then(response => setRecommendRecords(response.entries ?? []))
|
|
361
|
+
.catch(() => { /* best-effort */ })
|
|
362
|
+
}
|
|
363
|
+
useEffect(() => { loadRecommendRecords() }, [api]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
364
|
+
|
|
365
|
+
const recommendVoice = async (): Promise<void> => {
|
|
366
|
+
const text = prompt.trim()
|
|
367
|
+
if (text === '') {
|
|
368
|
+
setRecommendError('请先描述你想要的音色,例如「17岁清亮甜美的少女音,适合活泼女主角」。')
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (recommendChannelId === '') {
|
|
372
|
+
setRecommendError('请先选择渠道(需要在设置中添加且填写 API 地址与密钥)。')
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
setRecommending(true)
|
|
376
|
+
setRecommendError(null)
|
|
377
|
+
setRecommendResult(null)
|
|
378
|
+
try {
|
|
379
|
+
const response = await api.voiceRecommend({ channel: recommendChannelId, requirement: text, top_k: recommendTopK })
|
|
380
|
+
if (response.ok) {
|
|
381
|
+
setRecommendResult({
|
|
382
|
+
channelId: recommendChannelId,
|
|
383
|
+
channel: response.channel ?? '',
|
|
384
|
+
requirement: text,
|
|
385
|
+
candidateCount: response.candidate_count ?? 0,
|
|
386
|
+
voices: response.recommendations ?? [],
|
|
387
|
+
})
|
|
388
|
+
void loadRecommendRecords()
|
|
389
|
+
props.showToast('推荐完成,已记录到右侧「推荐记录」')
|
|
390
|
+
} else {
|
|
391
|
+
setRecommendError(response.message ?? '音色推荐失败')
|
|
392
|
+
}
|
|
393
|
+
} catch (err) {
|
|
394
|
+
setRecommendError(err instanceof Error ? err.message : String(err))
|
|
395
|
+
} finally {
|
|
396
|
+
setRecommending(false)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 从推荐结果/记录复用音色:切回 TTS 并回填音色与模型。 */
|
|
401
|
+
const reuseRecommendVoice = (voiceId: string, channelId: string): void => {
|
|
402
|
+
const channel = channels.find(item => item.id === channelId)
|
|
403
|
+
const ttsModel = channel?.models.find(candidate => candidate.category === 'tts' || /tts|speech|voice|t2a/i.test(candidate.alias))
|
|
404
|
+
setMode('tts')
|
|
405
|
+
setVoice(voiceId)
|
|
406
|
+
if (ttsModel !== undefined && ttsModel.alias !== '') setModel(ttsModel.alias)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const removeRecommendRecord = async (id: string): Promise<void> => {
|
|
410
|
+
await api.voiceRecommendHistoryRemove(id).catch(() => { /* best-effort */ })
|
|
411
|
+
loadRecommendRecords()
|
|
412
|
+
}
|
|
413
|
+
|
|
333
414
|
const visibleModels = useMemo(() => {
|
|
334
|
-
if (mode === 'voice_design') return []
|
|
415
|
+
if (mode === 'voice_design' || mode === 'voice_recommend') return []
|
|
335
416
|
return modelOptions.models
|
|
336
417
|
.filter(entry => modelSuitableForMode(entry, mode))
|
|
337
418
|
.map(entry => entry.alias)
|
|
@@ -340,9 +421,10 @@ export function StudioView(props: {
|
|
|
340
421
|
// 当前模型所属渠道 preset(单模型模式);对比模式为所选模型渠道集合。
|
|
341
422
|
const currentPreset = useMemo(() => {
|
|
342
423
|
if (mode === 'voice_design') return channels.find(candidate => candidate.id === designChannelId)?.preset ?? ''
|
|
424
|
+
if (mode === 'voice_recommend') return channels.find(candidate => candidate.id === recommendChannelId)?.preset ?? ''
|
|
343
425
|
const picked = modelOptions.models.find(entry => entry.alias === model) ?? modelOptions.models.find(entry => entry.alias === (visibleModels[0] ?? ''))
|
|
344
426
|
return picked?.preset ?? ''
|
|
345
|
-
}, [mode, model, visibleModels, modelOptions.models, channels, designChannelId])
|
|
427
|
+
}, [mode, model, visibleModels, modelOptions.models, channels, designChannelId, recommendChannelId])
|
|
346
428
|
|
|
347
429
|
const fieldPresets = useMemo(() => {
|
|
348
430
|
if (mode === 'voice_design') return []
|
|
@@ -405,9 +487,12 @@ export function StudioView(props: {
|
|
|
405
487
|
if (reuse.model !== undefined && reuse.model !== '') setModel(reuse.model)
|
|
406
488
|
}, [reuse?.nonce])
|
|
407
489
|
|
|
490
|
+
/** 音色推荐模式不参与生成:生成请求一律收敛为音频模式(运行时不会触发)。 */
|
|
491
|
+
const genMode: AudioMode = mode === 'voice_recommend' ? 'tts' : mode
|
|
492
|
+
|
|
408
493
|
/** Build the shared generation request for one model. */
|
|
409
494
|
const requestOf = (modelName: string): GenerateAudioRequest => ({
|
|
410
|
-
mode,
|
|
495
|
+
mode: genMode,
|
|
411
496
|
model: modelName,
|
|
412
497
|
prompt: prompt.trim(),
|
|
413
498
|
saveToLibrary,
|
|
@@ -478,7 +563,7 @@ export function StudioView(props: {
|
|
|
478
563
|
}))
|
|
479
564
|
const task: StudioTask = {
|
|
480
565
|
id: taskId,
|
|
481
|
-
mode,
|
|
566
|
+
mode: genMode,
|
|
482
567
|
prompt: prompt.trim(),
|
|
483
568
|
kind: isCompare ? 'compare' : 'single',
|
|
484
569
|
label: isCompare ? `对比 ${models.join(' / ')}` : singleModel,
|
|
@@ -934,7 +1019,7 @@ export function StudioView(props: {
|
|
|
934
1019
|
type="button"
|
|
935
1020
|
className={css.ghostButton}
|
|
936
1021
|
onClick={() => openSaveDialog([audio], {
|
|
937
|
-
mode,
|
|
1022
|
+
mode: genMode,
|
|
938
1023
|
prompt: prompt.trim(),
|
|
939
1024
|
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
940
1025
|
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
@@ -1010,7 +1095,7 @@ export function StudioView(props: {
|
|
|
1010
1095
|
return merged.sort((left, right) => right.createdAt - left.createdAt)
|
|
1011
1096
|
}, [entries])
|
|
1012
1097
|
|
|
1013
|
-
const needModel = mode !== 'voice_design'
|
|
1098
|
+
const needModel = mode !== 'voice_design' && mode !== 'voice_recommend'
|
|
1014
1099
|
const runningCount = tasks.filter(task => task.status === 'running').length
|
|
1015
1100
|
/** 结果列只显示当前模式的任务(历史面板已按模式分组)。 */
|
|
1016
1101
|
const visibleTasks = useMemo(() => tasks.filter(task => task.mode === mode), [tasks, mode])
|
|
@@ -1019,7 +1104,7 @@ export function StudioView(props: {
|
|
|
1019
1104
|
<div className={css.studio}>
|
|
1020
1105
|
<div className={css.formCol}>
|
|
1021
1106
|
<div className={css.modeRow}>
|
|
1022
|
-
{([['tts', '🎙️'], ['music', '🎵'], ['sfx', '🔊'], ['voice_design', '🎨']] as Array<[
|
|
1107
|
+
{([['tts', '🎙️'], ['music', '🎵'], ['sfx', '🔊'], ['voice_design', '🎨'], ['voice_recommend', '🎤']] as Array<[StudioMode, string]>).map(([item, icon]) => (
|
|
1023
1108
|
<button
|
|
1024
1109
|
key={item}
|
|
1025
1110
|
type="button"
|
|
@@ -1035,15 +1120,22 @@ export function StudioView(props: {
|
|
|
1035
1120
|
|
|
1036
1121
|
<p className={css.formSection}>输入</p>
|
|
1037
1122
|
<label className={css.label}>
|
|
1038
|
-
<span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
|
|
1039
|
-
<textarea
|
|
1123
|
+
<span>{mode === 'voice_design' ? '音色描述' : mode === 'voice_recommend' ? '音色需求' : mode === 'tts' ? '文本' : '提示词'}</span>
|
|
1124
|
+
<textarea
|
|
1125
|
+
className={css.textarea}
|
|
1126
|
+
value={prompt}
|
|
1127
|
+
onChange={event => setPrompt(event.target.value)}
|
|
1128
|
+
placeholder={mode === 'voice_recommend' ? '描述你想要的音色,例如:17岁清亮甜美的少女音,适合活泼女主角,英式口音…' : tt('prompt.placeholder')}
|
|
1129
|
+
/>
|
|
1040
1130
|
</label>
|
|
1041
|
-
|
|
1042
|
-
<
|
|
1043
|
-
{enhancing
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1131
|
+
{mode !== 'voice_recommend' ? (
|
|
1132
|
+
<div className={css.enhanceActionsRow}>
|
|
1133
|
+
<button type="button" className={css.ghostButton} disabled={enhancing} onClick={() => void runEnhance()}>
|
|
1134
|
+
{enhancing ? '增强中…' : '✨ 增强提示词'}
|
|
1135
|
+
</button>
|
|
1136
|
+
</div>
|
|
1137
|
+
) : null}
|
|
1138
|
+
{enhancePreview !== null && mode !== 'voice_recommend' ? (
|
|
1047
1139
|
<div className={css.enhanceCard}>
|
|
1048
1140
|
<div className={css.enhanceCardHead}>
|
|
1049
1141
|
<strong>增强结果({modeLabelOf(mode)})</strong>
|
|
@@ -1076,6 +1168,27 @@ export function StudioView(props: {
|
|
|
1076
1168
|
<input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
|
|
1077
1169
|
</label>
|
|
1078
1170
|
</>
|
|
1171
|
+
) : mode === 'voice_recommend' ? (
|
|
1172
|
+
<>
|
|
1173
|
+
<label className={css.label}>
|
|
1174
|
+
<span>厂商 / 渠道</span>
|
|
1175
|
+
<select className={css.input} value={recommendChannelId} onChange={event => setRecommendChannelId(event.target.value)}>
|
|
1176
|
+
{channels.map(channel => (
|
|
1177
|
+
<option key={channel.id} value={channel.id}>{channel.name}</option>
|
|
1178
|
+
))}
|
|
1179
|
+
</select>
|
|
1180
|
+
</label>
|
|
1181
|
+
<label className={css.label}>
|
|
1182
|
+
<span>推荐数量</span>
|
|
1183
|
+
<select className={css.input} value={String(recommendTopK)} onChange={event => setRecommendTopK(Number(event.target.value))}>
|
|
1184
|
+
<option value="3">3 条</option>
|
|
1185
|
+
<option value="5">5 条</option>
|
|
1186
|
+
<option value="8">8 条</option>
|
|
1187
|
+
<option value="10">10 条</option>
|
|
1188
|
+
</select>
|
|
1189
|
+
</label>
|
|
1190
|
+
<p className={css.hint}>按需求描述挑选:候选范围为该渠道全部可用音色(语言/口音等由描述自然约束),结果会记录到右侧「推荐记录」</p>
|
|
1191
|
+
</>
|
|
1079
1192
|
) : null}
|
|
1080
1193
|
|
|
1081
1194
|
<p className={css.formSection}>模型</p>
|
|
@@ -1195,25 +1308,102 @@ export function StudioView(props: {
|
|
|
1195
1308
|
</details>
|
|
1196
1309
|
) : null}
|
|
1197
1310
|
|
|
1198
|
-
|
|
1199
|
-
<
|
|
1200
|
-
|
|
1201
|
-
|
|
1311
|
+
{mode !== 'voice_recommend' ? (
|
|
1312
|
+
<label className={css.checkbox} title="生成完成后自动保存到资源库;也可在设置中开启全部自动保存">
|
|
1313
|
+
<input type="checkbox" checked={saveToLibrary} onChange={event => setSaveToLibrary(event.target.checked)} />
|
|
1314
|
+
<span>生成后保存到资源库</span>
|
|
1315
|
+
</label>
|
|
1316
|
+
) : null}
|
|
1202
1317
|
|
|
1203
1318
|
{!connected && <p className={css.hint}>{tt('config.missing')}</p>}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1319
|
+
{mode === 'voice_recommend' ? (
|
|
1320
|
+
<button
|
|
1321
|
+
type="button"
|
|
1322
|
+
className={css.generate}
|
|
1323
|
+
disabled={!connected || recommending}
|
|
1324
|
+
onClick={() => void recommendVoice()}
|
|
1325
|
+
>
|
|
1326
|
+
{recommending ? 'AI 推荐中…' : '✨ AI 推荐音色'}
|
|
1327
|
+
</button>
|
|
1328
|
+
) : (
|
|
1329
|
+
<button
|
|
1330
|
+
type="button"
|
|
1331
|
+
className={css.generate}
|
|
1332
|
+
disabled={!connected || (compareMode && needModel ? compareModels.length < 2 : needModel && visibleModels.length === 0)}
|
|
1333
|
+
onClick={submit}
|
|
1334
|
+
>
|
|
1335
|
+
{(compareMode && needModel ? '对比生成' : tt('generate'))}
|
|
1336
|
+
</button>
|
|
1337
|
+
)}
|
|
1212
1338
|
{runningCount > 0 ? <p className={css.hint}>进行中任务:{runningCount} 个(并发上限在「设置 → 插件 → AI 音频」调整)</p> : null}
|
|
1213
1339
|
</div>
|
|
1214
1340
|
|
|
1215
1341
|
<div className={css.resultCol}>
|
|
1216
|
-
{
|
|
1342
|
+
{mode === 'voice_recommend' ? (
|
|
1343
|
+
<>
|
|
1344
|
+
{recommendError !== null ? <p className={css.error}>{recommendError}</p> : null}
|
|
1345
|
+
{recommending ? (
|
|
1346
|
+
<div className={css.resultEmpty}>
|
|
1347
|
+
<span className={css.resultEmptyIcon}>🎤</span>
|
|
1348
|
+
<p>正在让模型从候选音色中挑选…(约 10-45 秒,结果会记录到右侧)</p>
|
|
1349
|
+
</div>
|
|
1350
|
+
) : null}
|
|
1351
|
+
{!recommending && recommendResult !== null ? (
|
|
1352
|
+
<div className={css.taskList}>
|
|
1353
|
+
<div className={css.taskCard}>
|
|
1354
|
+
<div className={css.taskHead}>
|
|
1355
|
+
<span className={css.resultModeChip}>音色推荐</span>
|
|
1356
|
+
<span className={css.taskLabel} title={recommendResult.requirement}>{recommendResult.requirement}</span>
|
|
1357
|
+
</div>
|
|
1358
|
+
<div className={css.historyMeta}>
|
|
1359
|
+
{recommendResult.channel} · 候选池 {recommendResult.candidateCount} 个音色 · 推荐 {recommendResult.voices.length} 条
|
|
1360
|
+
</div>
|
|
1361
|
+
{recommendResult.voices.map((voice, index) => (
|
|
1362
|
+
<div className={css.recVoiceRow} key={`${voice.source}:${voice.voice_id}`}>
|
|
1363
|
+
<span className={css.recRank}>{index + 1}</span>
|
|
1364
|
+
<div className={css.recVoiceBody}>
|
|
1365
|
+
<div className={css.recVoiceHead}>
|
|
1366
|
+
<strong className={css.recVoiceName} title={voice.name}>{voice.name}</strong>
|
|
1367
|
+
</div>
|
|
1368
|
+
{voice.reason === '' ? null : <p className={css.recReason}>{voice.reason}</p>}
|
|
1369
|
+
<div className={css.historyMeta}>
|
|
1370
|
+
ID: {voice.voice_id}{voice.language === undefined ? '' : ` · ${voice.language}`}{voice.locale === undefined ? '' : ` · ${voice.locale}`}{voice.accent === undefined ? '' : ` · ${voice.accent}`}{voice.gender === undefined ? '' : ` · ${voice.gender}`}{voice.age === undefined ? '' : ` · ${voice.age}`}{voice.use_case === undefined ? '' : ` · ${voice.use_case}`}
|
|
1371
|
+
</div>
|
|
1372
|
+
{voice.descriptive === undefined && (voice.labels === undefined || Object.keys(voice.labels).length === 0) ? null : (
|
|
1373
|
+
<div className={css.historyMeta}>
|
|
1374
|
+
{voice.descriptive !== undefined ? `标签 descriptive: ${voice.descriptive}` : ''}
|
|
1375
|
+
{voice.labels !== undefined ? Object.entries(voice.labels).filter(([key]) => key !== 'description' && key !== 'descriptive').map(([key, value]) => `${key === 'use_case' ? '用途' : key === 'accent' ? '口音' : key === 'gender' ? '性别' : key === 'age' ? '年龄' : key}: ${value}`).join(' · ') : ''}
|
|
1376
|
+
</div>
|
|
1377
|
+
)}
|
|
1378
|
+
{voice.description === undefined || voice.description === '' ? null : <p className={css.recDescription}>{voice.description}</p>}
|
|
1379
|
+
{voice.preview_url === undefined || voice.preview_url === '' ? null : <AudioPlayer src={voice.preview_url} compact />}
|
|
1380
|
+
</div>
|
|
1381
|
+
<button type="button" className={css.ghostButton} onClick={() => reuseRecommendVoice(voice.voice_id, recommendResult.channelId)}>
|
|
1382
|
+
用此音色生成
|
|
1383
|
+
</button>
|
|
1384
|
+
</div>
|
|
1385
|
+
))}
|
|
1386
|
+
</div>
|
|
1387
|
+
</div>
|
|
1388
|
+
) : null}
|
|
1389
|
+
{!recommending && recommendResult === null ? (
|
|
1390
|
+
<div className={css.resultEmpty}>
|
|
1391
|
+
<span className={css.resultEmptyIcon}>🎤</span>
|
|
1392
|
+
<p>在左侧描述想要的音色,点「✨ AI 推荐音色」</p>
|
|
1393
|
+
<p className={css.resultEmptyHint}>模型会从该渠道候选池里挑最匹配的 top-k 音色;每次推荐自动记录到右侧「推荐记录」</p>
|
|
1394
|
+
<button
|
|
1395
|
+
type="button"
|
|
1396
|
+
className={css.ghostButton}
|
|
1397
|
+
onClick={() => setPrompt('17岁清亮甜美的少女音,适合活泼女主角,英式口音')}
|
|
1398
|
+
>
|
|
1399
|
+
填入示例需求
|
|
1400
|
+
</button>
|
|
1401
|
+
</div>
|
|
1402
|
+
) : null}
|
|
1403
|
+
</>
|
|
1404
|
+
) : (
|
|
1405
|
+
<>
|
|
1406
|
+
{error !== null ? <p className={css.error}>{error}</p> : null}
|
|
1217
1407
|
{visibleTasks.length === 0 ? (
|
|
1218
1408
|
<div className={css.resultEmpty}>
|
|
1219
1409
|
<span className={css.resultEmptyIcon}>🎵</span>
|
|
@@ -1286,9 +1476,58 @@ export function StudioView(props: {
|
|
|
1286
1476
|
})}
|
|
1287
1477
|
</div>
|
|
1288
1478
|
)}
|
|
1479
|
+
</>
|
|
1480
|
+
)}
|
|
1289
1481
|
</div>
|
|
1290
1482
|
|
|
1291
1483
|
<aside className={css.historyCol}>
|
|
1484
|
+
{mode === 'voice_recommend' ? (
|
|
1485
|
+
<>
|
|
1486
|
+
<div className={css.historyHeader}>
|
|
1487
|
+
<strong className={css.historyTitle}>推荐记录</strong>
|
|
1488
|
+
</div>
|
|
1489
|
+
{recommendRecords.length === 0 ? (
|
|
1490
|
+
<p className={css.historyEmpty}>还没有推荐记录——在左侧输入音色需求,点「✨ AI 推荐音色」后自动记录在这里</p>
|
|
1491
|
+
) : (
|
|
1492
|
+
<div className={css.historyList}>
|
|
1493
|
+
{recommendRecords.map(record => (
|
|
1494
|
+
<details className={css.historyItem} key={record.id}>
|
|
1495
|
+
<summary className={css.historyCompareSummary}>
|
|
1496
|
+
<span className={css.historyPrompt}>{record.requirement}</span>
|
|
1497
|
+
<span className={css.historyCompareBadge}>{record.channel} · {record.recommendations.length} 条</span>
|
|
1498
|
+
<span className={css.historyTime}>{formatClock(record.createdAt)}</span>
|
|
1499
|
+
</summary>
|
|
1500
|
+
<div className={css.historyMeta}>候选池 {record.candidate_count} 个音色</div>
|
|
1501
|
+
{record.recommendations.map((voice, index) => (
|
|
1502
|
+
<div className={css.recVoiceRow} key={`${record.id}:${voice.voice_id}`}>
|
|
1503
|
+
<span className={css.recRank}>{index + 1}</span>
|
|
1504
|
+
<div className={css.recVoiceBody}>
|
|
1505
|
+
<div className={css.recVoiceHead}>
|
|
1506
|
+
<strong className={css.recVoiceName} title={voice.name}>{voice.name}</strong>
|
|
1507
|
+
</div>
|
|
1508
|
+
{voice.reason === '' ? null : <p className={css.recReason}>{voice.reason}</p>}
|
|
1509
|
+
<div className={css.historyMeta}>ID: {voice.voice_id}{voice.descriptive === undefined ? '' : ` · ${voice.descriptive}`}{voice.accent === undefined ? '' : ` · ${voice.accent}`}{voice.gender === undefined ? '' : ` · ${voice.gender}`}{voice.age === undefined ? '' : ` · ${voice.age}`}</div>
|
|
1510
|
+
{voice.description === undefined || voice.description === '' ? null : <p className={css.recDescription}>{voice.description.length > 120 ? `${voice.description.slice(0, 119)}…` : voice.description}</p>}
|
|
1511
|
+
</div>
|
|
1512
|
+
<button
|
|
1513
|
+
type="button"
|
|
1514
|
+
className={css.ghostButton}
|
|
1515
|
+
onClick={() => reuseRecommendVoice(voice.voice_id, record.channel_id ?? recommendChannelId)}
|
|
1516
|
+
>
|
|
1517
|
+
用此音色生成
|
|
1518
|
+
</button>
|
|
1519
|
+
</div>
|
|
1520
|
+
))}
|
|
1521
|
+
<div className={css.historyActions}>
|
|
1522
|
+
<button type="button" className={css.historyIcon} title="删除这条推荐记录" onClick={() => void removeRecommendRecord(record.id)}>✕</button>
|
|
1523
|
+
</div>
|
|
1524
|
+
</details>
|
|
1525
|
+
))}
|
|
1526
|
+
</div>
|
|
1527
|
+
)}
|
|
1528
|
+
</>
|
|
1529
|
+
) : (
|
|
1530
|
+
<>
|
|
1292
1531
|
<div className={css.historyHeader}>
|
|
1293
1532
|
<strong className={css.historyTitle}>{tt('history.title')}</strong>
|
|
1294
1533
|
<button type="button" className={css.historyClear} onClick={clear}>清空</button>
|
|
@@ -1383,6 +1622,8 @@ export function StudioView(props: {
|
|
|
1383
1622
|
</div>
|
|
1384
1623
|
</>
|
|
1385
1624
|
)}
|
|
1625
|
+
</>
|
|
1626
|
+
)}
|
|
1386
1627
|
</aside>
|
|
1387
1628
|
|
|
1388
1629
|
{saveDialog !== null ? (
|