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/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 listVendorVoices(channel, {
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',