dsh-audiogen 0.4.19 → 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 +1003 -407
- package/lib/client.js.map +1 -1
- package/lib/index.js +1327 -50
- 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 +129 -17
- 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 +82 -7
- package/src/voice-recommend.ts +350 -0
package/src/routes.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { randomUUID } from 'node:crypto'
|
|
|
11
11
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
12
12
|
import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
|
|
13
13
|
import { generateAudio, AudioGenError, type AudioChannel } from './audio-engine.ts'
|
|
14
|
-
import { listVendorVoices, deleteVendorVoice } from './voice-manager.ts'
|
|
14
|
+
import { listVendorVoices, listVendorVoicesWithFallback, deleteVendorVoice, type ListVoicesOptions, type VendorVoiceEntry } from './voice-manager.ts'
|
|
15
|
+
import { recommendVoices, type VoiceRecommendation } from './voice-recommend.ts'
|
|
16
|
+
import { appendVoiceRecommendRecord, listVoiceRecommendRecords, removeVoiceRecommendRecord } from './voice-recommend.ts'
|
|
15
17
|
import type { GenerationBudget } from './audio-scheduler.ts'
|
|
16
18
|
import { discoverAudioModels } from './audio-models.ts'
|
|
17
19
|
import { AUDIO_PRESETS } from './audio-presets.ts'
|
|
@@ -50,6 +52,8 @@ export interface AudiogenRoutesDeps {
|
|
|
50
52
|
budget: GenerationBudget
|
|
51
53
|
/** 提示词增强:调用 Agent 默认模型,返回增强后的文本。 */
|
|
52
54
|
enhance: (prompt: string, mode: GenerateAudioRequest['mode']) => Promise<string>
|
|
55
|
+
/** 音色推荐:需求描述 + 候选池 → top-k 推荐(复用 Agent 默认模型)。 */
|
|
56
|
+
recommend: (requirement: string, candidates: VendorVoiceEntry[], topK: number) => Promise<VoiceRecommendation[]>
|
|
53
57
|
/** 「设置 → 模型」提供方列表 + 各自可广播模型(增强模型下拉候选)。 */
|
|
54
58
|
llmModelOptions: () => Promise<LlmModelOption[]>
|
|
55
59
|
}
|
|
@@ -302,9 +306,52 @@ async function mergeLibraryProvenance(
|
|
|
302
306
|
}
|
|
303
307
|
}
|
|
304
308
|
|
|
309
|
+
/** 从请求体解析音色列表/推荐共用的筛选选项。 */
|
|
310
|
+
function voiceListOptionsOf(body: Record<string, unknown> | undefined): ListVoicesOptions {
|
|
311
|
+
const str = (key: string): string | undefined => {
|
|
312
|
+
const value = body?.[key]
|
|
313
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
314
|
+
}
|
|
315
|
+
const filters = {
|
|
316
|
+
...(str('search') === undefined ? {} : { search: str('search')! }),
|
|
317
|
+
...(str('use_case') === undefined ? {} : { use_case: str('use_case')! }),
|
|
318
|
+
...(str('accent') === undefined ? {} : { accent: str('accent')! }),
|
|
319
|
+
...(str('gender') === undefined ? {} : { gender: str('gender')! }),
|
|
320
|
+
...(str('age') === undefined ? {} : { age: str('age')! }),
|
|
321
|
+
...(str('locale') === undefined ? {} : { locale: str('locale')! }),
|
|
322
|
+
...(str('category') === undefined ? {} : { category: str('category')! }),
|
|
323
|
+
...(str('sort') === undefined ? {} : { sort: str('sort')! }),
|
|
324
|
+
...(body?.featured === true ? { featured: true } : {}),
|
|
325
|
+
...(body?.free_users_allowed === true ? { free_users_allowed: true } : {}),
|
|
326
|
+
...(body?.descriptive === true ? { descriptive: true } : {}),
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
...(str('language') === undefined ? {} : { language: str('language')! }),
|
|
330
|
+
...(str('keyword') === undefined ? {} : { keyword: str('keyword')! }),
|
|
331
|
+
...(str('source') === undefined ? {} : { source: str('source')! }),
|
|
332
|
+
...(typeof body?.limit === 'number' && Number.isFinite(body.limit) ? { limit: Math.floor(body.limit) } : {}),
|
|
333
|
+
...(Object.keys(filters).length === 0 ? {} : { serverFilters: filters }),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 推荐记录的筛选条件快照(面板展示用,不含密钥)。 */
|
|
338
|
+
function recommendFiltersOf(body: Record<string, unknown> | undefined): Record<string, string | boolean> {
|
|
339
|
+
const str = (key: string): string | undefined => {
|
|
340
|
+
const value = body?.[key]
|
|
341
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
342
|
+
}
|
|
343
|
+
const out: Record<string, string | boolean> = {}
|
|
344
|
+
for (const key of ['language', 'keyword', 'source', 'search', 'use_case', 'accent', 'gender', 'age', 'locale', 'category', 'sort'] as const) {
|
|
345
|
+
const value = str(key)
|
|
346
|
+
if (value !== undefined) out[key] = value
|
|
347
|
+
}
|
|
348
|
+
if (body?.featured === true) out.featured = true
|
|
349
|
+
if (body?.free_users_allowed === true) out.free_users_allowed = true
|
|
350
|
+
return out
|
|
351
|
+
}
|
|
352
|
+
|
|
305
353
|
/** Build every /api/dsh-audiogen route. */
|
|
306
|
-
export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
307
|
-
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
354
|
+
export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] { const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
308
355
|
if (!isLoopbackRequest(req)) {
|
|
309
356
|
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
310
357
|
return false
|
|
@@ -404,31 +451,8 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
|
404
451
|
writeJson(res, 200, { ok: false, code: 'channel-not-configured', message: '没有可用的音频渠道(需要已配置 API 地址与密钥),请先在设置中添加。' })
|
|
405
452
|
return
|
|
406
453
|
}
|
|
407
|
-
const str = (key: string): string | undefined => {
|
|
408
|
-
const value = body?.[key]
|
|
409
|
-
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
410
|
-
}
|
|
411
|
-
const filters = {
|
|
412
|
-
...(str('search') === undefined ? {} : { search: str('search')! }),
|
|
413
|
-
...(str('use_case') === undefined ? {} : { use_case: str('use_case')! }),
|
|
414
|
-
...(str('accent') === undefined ? {} : { accent: str('accent')! }),
|
|
415
|
-
...(str('gender') === undefined ? {} : { gender: str('gender')! }),
|
|
416
|
-
...(str('age') === undefined ? {} : { age: str('age')! }),
|
|
417
|
-
...(str('locale') === undefined ? {} : { locale: str('locale')! }),
|
|
418
|
-
...(str('category') === undefined ? {} : { category: str('category')! }),
|
|
419
|
-
...(str('sort') === undefined ? {} : { sort: str('sort')! }),
|
|
420
|
-
...(body?.featured === true ? { featured: true } : {}),
|
|
421
|
-
...(body?.free_users_allowed === true ? { free_users_allowed: true } : {}),
|
|
422
|
-
...(body?.descriptive === true ? { descriptive: true } : {}),
|
|
423
|
-
}
|
|
424
454
|
try {
|
|
425
|
-
const result = await
|
|
426
|
-
...(str('language') === undefined ? {} : { language: str('language')! }),
|
|
427
|
-
...(str('keyword') === undefined ? {} : { keyword: str('keyword')! }),
|
|
428
|
-
...(str('source') === undefined ? {} : { source: str('source')! }),
|
|
429
|
-
...(typeof body?.limit === 'number' && Number.isFinite(body.limit) ? { limit: Math.floor(body.limit) } : {}),
|
|
430
|
-
...(Object.keys(filters).length === 0 ? {} : { serverFilters: filters }),
|
|
431
|
-
})
|
|
455
|
+
const result = await listVendorVoicesWithFallback(channel, voiceListOptionsOf(body))
|
|
432
456
|
writeJson(res, 200, {
|
|
433
457
|
ok: true,
|
|
434
458
|
vendor: result.vendor,
|
|
@@ -471,6 +495,112 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
|
471
495
|
}
|
|
472
496
|
},
|
|
473
497
|
},
|
|
498
|
+
// ------------------------------------------ vendor voice recommend (LLM)
|
|
499
|
+
{
|
|
500
|
+
kind: 'exact',
|
|
501
|
+
path: VOICES_API.recommend,
|
|
502
|
+
handler: async (req, res) => {
|
|
503
|
+
if (!guard(req, res, 'POST')) return
|
|
504
|
+
const body = await readJsonBody(req) as Record<string, unknown> | undefined
|
|
505
|
+
const requirement = typeof body?.requirement === 'string' ? body.requirement.trim() : ''
|
|
506
|
+
if (requirement === '') {
|
|
507
|
+
writeJson(res, 200, { ok: false, code: 'recommend-requirement-required', message: '需求描述(requirement)不能为空。' })
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
const channel = channelOf(deps.resolveChannels(), body?.channel)
|
|
511
|
+
if (channel === undefined) {
|
|
512
|
+
writeJson(res, 200, { ok: false, code: 'channel-not-configured', message: '没有可用的音频渠道(需要已配置 API 地址与密钥),请先在设置中添加。' })
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
const rawTopK = body?.top_k
|
|
516
|
+
const topK = typeof rawTopK === 'number' && Number.isFinite(rawTopK)
|
|
517
|
+
? Math.max(1, Math.min(10, Math.floor(rawTopK)))
|
|
518
|
+
: 5
|
|
519
|
+
try {
|
|
520
|
+
// 推荐面向足够宽的候选池:不传 limit 用默认上限,避免默认 100 太窄。
|
|
521
|
+
const result = await listVendorVoicesWithFallback(channel, {
|
|
522
|
+
...voiceListOptionsOf(body),
|
|
523
|
+
limit: 500,
|
|
524
|
+
})
|
|
525
|
+
const recommendations = await deps.recommend(requirement, result.voices, topK)
|
|
526
|
+
// 记录本次推荐(最近 50 条,先记录后返回;失败不影响结果)。
|
|
527
|
+
void appendVoiceRecommendRecord({
|
|
528
|
+
channel: channel.name,
|
|
529
|
+
vendor: result.vendor,
|
|
530
|
+
requirement,
|
|
531
|
+
candidate_count: result.voices.length,
|
|
532
|
+
top_k: topK,
|
|
533
|
+
channel_id: channel.id,
|
|
534
|
+
filters: recommendFiltersOf(body),
|
|
535
|
+
recommendations: recommendations.map(item => ({
|
|
536
|
+
voice_id: item.voice_id,
|
|
537
|
+
name: item.name,
|
|
538
|
+
source: item.source,
|
|
539
|
+
deletable: item.deletable,
|
|
540
|
+
...(item.language === undefined ? {} : { language: item.language }),
|
|
541
|
+
...(item.locale === undefined ? {} : { locale: item.locale }),
|
|
542
|
+
...(item.accent === undefined ? {} : { accent: item.accent }),
|
|
543
|
+
...(item.gender === undefined ? {} : { gender: item.gender }),
|
|
544
|
+
...(item.age === undefined ? {} : { age: item.age }),
|
|
545
|
+
...(item.use_case === undefined ? {} : { use_case: item.use_case }),
|
|
546
|
+
...(item.category === undefined ? {} : { category: item.category }),
|
|
547
|
+
...(item.labels === undefined ? {} : { labels: item.labels }),
|
|
548
|
+
...(item.descriptive === undefined ? {} : { descriptive: item.descriptive }),
|
|
549
|
+
...(item.description === undefined ? {} : { description: item.description }),
|
|
550
|
+
...(item.preview_url === undefined ? {} : { preview_url: item.preview_url }),
|
|
551
|
+
reason: item.reason,
|
|
552
|
+
})),
|
|
553
|
+
}).catch(() => { /* best-effort */ })
|
|
554
|
+
writeJson(res, 200, {
|
|
555
|
+
ok: true,
|
|
556
|
+
vendor: result.vendor,
|
|
557
|
+
channel: channel.name,
|
|
558
|
+
requirement,
|
|
559
|
+
candidate_count: result.voices.length,
|
|
560
|
+
top_k: topK,
|
|
561
|
+
recommendations,
|
|
562
|
+
recorded: true,
|
|
563
|
+
...(result.note === undefined ? {} : { note: result.note }),
|
|
564
|
+
})
|
|
565
|
+
} catch (error) {
|
|
566
|
+
writeJson(res, 200, { ok: false, code: 'voice-recommend-failed', message: messageOf(error) })
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
// ------------------------------------------ voice recommend history (records)
|
|
571
|
+
{
|
|
572
|
+
kind: 'exact',
|
|
573
|
+
path: VOICES_API.recommendHistory.list,
|
|
574
|
+
handler: async (req, res) => {
|
|
575
|
+
if (!guard(req, res, 'POST')) return
|
|
576
|
+
const body = await readJsonBody(req) as Record<string, unknown> | undefined
|
|
577
|
+
const rawLimit = body?.limit
|
|
578
|
+
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit)
|
|
579
|
+
? Math.max(1, Math.min(50, Math.floor(rawLimit)))
|
|
580
|
+
: 20
|
|
581
|
+
const entries = await listVoiceRecommendRecords(limit)
|
|
582
|
+
writeJson(res, 200, { ok: true, count: entries.length, entries })
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
kind: 'exact',
|
|
587
|
+
path: VOICES_API.recommendHistory.remove,
|
|
588
|
+
handler: async (req, res) => {
|
|
589
|
+
if (!guard(req, res, 'POST')) return
|
|
590
|
+
const body = await readJsonBody(req) as Record<string, unknown> | undefined
|
|
591
|
+
const id = typeof body?.id === 'string' ? body.id.trim() : ''
|
|
592
|
+
if (id === '') {
|
|
593
|
+
writeJson(res, 200, { ok: false, code: 'record-id-required', message: 'record id 不能为空。' })
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
await removeVoiceRecommendRecord(id)
|
|
598
|
+
writeJson(res, 200, { ok: true, removed: id })
|
|
599
|
+
} catch (error) {
|
|
600
|
+
writeJson(res, 200, { ok: false, code: 'record-remove-failed', message: messageOf(error) })
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
},
|
|
474
604
|
// -------------------------------------------------- settings describe
|
|
475
605
|
{
|
|
476
606
|
kind: 'exact',
|