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.
@@ -0,0 +1,712 @@
1
+ /**
2
+ * 角色音色选角(voice casting):把「角色画像 → 候选音色 → 主音色 + 备用音色」
3
+ * 流水线落到插件里,等价于 standalone 音频工作室里 MiniMax/ElevenLabs 的选角逻辑。
4
+ *
5
+ * 分工(重要):
6
+ * - 本模块只做确定性的事:角色画像归一化、硬过滤(gender/age/use_case 严格,
7
+ * accent 可放松)、投票校验(voice_id 必须属于该角色候选池、备份补齐、
8
+ * lead/major 主音色不重复)、选定记录持久化。
9
+ * - 「选谁」的推理/全局权衡由 Agent(DeepSeek Harness 当前模型)完成:
10
+ * action=cast 拿到每个角色的候选列表后,Agent 在上下文中统一选角,
11
+ * 再把结果交给 action=save_cast 校验落盘。这样既防幻觉(工具校验),
12
+ * 又保留整组阵容的全局视野(Agent 推理)。
13
+ *
14
+ * 与 audio_studio_standalone 的对应关系:
15
+ * - 输入结构 = tts/tools/build_character_adapter_input.py 的角色画像(主字段一致)
16
+ * - 硬过滤 = elevenlabs/voice-selection/tools/elevenlabs_voice_selection.py
17
+ * _map_gender / _map_age_filter / _filter_candidates_with_fallback
18
+ * - 校验 = _validate_voice_selection_plan(成员校验 + 备份补齐 + 复用检查)
19
+ */
20
+
21
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
22
+ import path from 'node:path'
23
+ import os from 'node:os'
24
+ import type { AudioChannel } from './audio-engine.ts'
25
+ import { AudioGenError } from './audio-engine.ts'
26
+ import { listVendorVoicesWithFallback, type VendorVoiceEntry } from './voice-manager.ts'
27
+
28
+ // ---------------------------------------------------------------- types
29
+
30
+ /** 输入的角色画像(宽松:中英文都接受,字段可缺省)。 */
31
+ export interface CharacterProfile {
32
+ character_id: string
33
+ character_name: string
34
+ /** 原始性别文本(男/女/male/female),由 mapGender 归一化。 */
35
+ gender?: string
36
+ /** 原始年龄段文本(少年/青年/中年/老年…),由 mapAgeFilter 归一化。 */
37
+ age_stage?: string[]
38
+ /** explicit | appearance_inferred | unknown —— 决定年龄字段的可信度优先级。 */
39
+ age_stage_source?: string
40
+ voice_traits?: string[]
41
+ personality_traits?: string[]
42
+ appearance?: string[]
43
+ /** 样例台词(voice_design 的 preview_text 来源),最多保留 3 句。 */
44
+ sample_lines?: Array<{ text: string; emotion_hint?: string }>
45
+ dialogue_count?: number
46
+ /** lead | major | supporting;缺省由 dialogue_count 推导(≥200 lead,≥50 major)。 */
47
+ importance_tier?: string
48
+ language?: string
49
+ use_case?: string
50
+ }
51
+
52
+ /** 给 Agent 的角色视图(slim:仅保留选角需要的字段,类型与工具输出 schema 对齐)。 */
53
+ export interface CastCharacterSlim {
54
+ character_id: string
55
+ character_name: string
56
+ gender?: string
57
+ age_stage?: string[]
58
+ age_stage_source?: string
59
+ voice_traits?: string[]
60
+ personality_traits?: string[]
61
+ appearance?: string[]
62
+ sample_lines?: Array<{ text: string; emotion_hint?: string }>
63
+ dialogue_count: number
64
+ importance_tier: string
65
+ language?: string
66
+ use_case?: string
67
+ }
68
+
69
+ /** 给 Agent 用的角色候选视图(slim,含映射后的过滤条件)。 */
70
+ export interface CastCharacterView {
71
+ character: CastCharacterSlim
72
+ mapped_filters: {
73
+ gender?: string
74
+ age?: string[]
75
+ fallback_age?: string[]
76
+ accent?: string
77
+ use_case?: string
78
+ language?: string
79
+ notes: string
80
+ }
81
+ candidate_count: number
82
+ candidate_voices: SlimVoice[]
83
+ note?: string
84
+ }
85
+
86
+ /** 候选音色的 slim 视图(图片/长描述压缩,保证模型上下文可读)。 */
87
+ export interface SlimVoice {
88
+ voice_id: string
89
+ name: string
90
+ source: string
91
+ deletable: boolean
92
+ language?: string
93
+ locale?: string
94
+ accent?: string
95
+ gender?: string
96
+ age?: string
97
+ use_case?: string
98
+ category?: string
99
+ /** 厂商返回的完整标签(ElevenLabs labels 原样保留)。 */
100
+ labels?: Record<string, string>
101
+ /** ElevenLabs descriptive 标签(如 serious / calm / whispery)。 */
102
+ descriptive?: string
103
+ description?: string
104
+ preview_url?: string
105
+ }
106
+
107
+ /** action=save_cast 的输入:Agent 选角结果(voice_id / backup_voice_ids / reason)。 */
108
+ export interface CastSelectionInput {
109
+ character_id: string
110
+ character_name?: string
111
+ voice_id: string
112
+ backup_voice_ids?: string[]
113
+ reason?: string
114
+ }
115
+
116
+ /** 校验 + 落盘后的选定记录。 */
117
+ export interface CastSelectionRecord {
118
+ character_id: string
119
+ character_name: string
120
+ voice_id: string
121
+ voice_name: string
122
+ backup_voice_ids: string[]
123
+ reason: string
124
+ dialogue_count: number
125
+ importance_tier: string
126
+ /** ok | fixed(备份自动补齐/修正) | tool_fallback(主音色不合法,工具兜底)。 */
127
+ selection_status: string
128
+ issues: string[]
129
+ selected_at: string
130
+ }
131
+
132
+ export interface CastIssue {
133
+ character_id: string
134
+ character_name: string
135
+ issue: string
136
+ detail: string
137
+ }
138
+
139
+ // ------------------------------------------------------------- constants
140
+
141
+ const MAX_CANDIDATES_PER_CHARACTER = 60
142
+ const MAX_SAMPLE_LINES = 3
143
+ const MAX_DESCRIPTION_CHARS = 400
144
+ const BACKUP_LIMIT = 2
145
+
146
+ const LEAD_DIALOGUE_COUNT = 200
147
+ const MAJOR_DIALOGUE_COUNT = 50
148
+
149
+ /** 性状拆分:与 standalone 的 TRAIT_SPLIT_RE 一致。 */
150
+ const TRAIT_SPLIT_RE = /[;;、,,/|]+/
151
+
152
+ // ------------------------------------------------------------- primitive
153
+
154
+ function isRecord(value: unknown): value is Record<string, unknown> {
155
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
156
+ }
157
+
158
+ function asString(value: unknown): string {
159
+ if (typeof value === 'string') return value.trim()
160
+ if (typeof value === 'number') return String(value)
161
+ return ''
162
+ }
163
+
164
+ function asStringList(value: unknown): string[] {
165
+ if (Array.isArray(value)) {
166
+ return value.map(item => asString(item)).filter(item => item !== '')
167
+ }
168
+ const text = asString(value)
169
+ return text === '' ? [] : [text]
170
+ }
171
+
172
+ function splitTraits(value: unknown): string[] {
173
+ const seen = new Set<string>()
174
+ const out: string[] = []
175
+ for (const raw of asStringList(value)) {
176
+ for (const part of raw.split(TRAIT_SPLIT_RE)) {
177
+ const item = part.trim()
178
+ if (item !== '' && !seen.has(item)) {
179
+ seen.add(item)
180
+ out.push(item)
181
+ }
182
+ }
183
+ }
184
+ return out
185
+ }
186
+
187
+ function asNumber(value: unknown): number | undefined {
188
+ const text = asString(value)
189
+ if (text === '') return undefined
190
+ const parsed = Number(text)
191
+ return Number.isFinite(parsed) ? parsed : undefined
192
+ }
193
+
194
+ /** 从角色名生成稳定的 character_id(缺省时自动补,与上游 char_u<hex> 风格一致)。 */
195
+ function slugifyCharacterId(characterName: string, fallback: string): string {
196
+ let slug = ''
197
+ for (const char of characterName.toLowerCase()) {
198
+ if (/[a-z0-9]/.test(char)) slug += char
199
+ else if (char === '-' || char === '_') slug += '_'
200
+ else if (char.trim() !== '') slug += `${slug.endsWith('_') || slug === '' ? '' : '_'}u${char.codePointAt(0)!.toString(16)}`
201
+ }
202
+ const parts = slug.split('_').filter(part => part !== '')
203
+ slug = parts.join('_') || fallback
204
+ return `char_${slug}`
205
+ }
206
+
207
+ function importanceTier(dialogueCount: number, explicit?: string): string {
208
+ if (explicit === 'lead' || explicit === 'major' || explicit === 'supporting') return explicit
209
+ if (dialogueCount >= LEAD_DIALOGUE_COUNT) return 'lead'
210
+ if (dialogueCount >= MAJOR_DIALOGUE_COUNT) return 'major'
211
+ return 'supporting'
212
+ }
213
+
214
+ // ---------------------------------------------------------------- profile
215
+
216
+ /** 接受 JSON 数组 / 单个对象 / {characters: [...]} / JSON 字符串 → 角色剖面。 */
217
+ export function parseCharacterProfiles(input: unknown): CharacterProfile[] {
218
+ let value = input
219
+ if (typeof value === 'string') {
220
+ const text = value.trim()
221
+ if (text === '') {
222
+ throw new AudioGenError(
223
+ 'characters 不能为空:请传入角色画像 JSON(数组或单个对象)或 JSON 字符串',
224
+ 'cast-characters-empty',
225
+ )
226
+ }
227
+ try {
228
+ value = JSON.parse(text)
229
+ } catch {
230
+ throw new AudioGenError(
231
+ 'characters 无法解析为 JSON:请先把角色信息整理成类似 '
232
+ + '[{"character_id": "char_xxx", "character_name": "名字", "gender": "女性", '
233
+ + '"age_stage": "少女", "voice_traits": ["清亮"], "personality_traits": ["活泼"], '
234
+ + '"appearance": ["…"], "dialogue_count": 12}] 的结构再调用',
235
+ 'cast-characters-parse-failed',
236
+ )
237
+ }
238
+ }
239
+
240
+ let list: unknown[]
241
+ if (Array.isArray(value)) {
242
+ list = value
243
+ } else if (isRecord(value)) {
244
+ const inner = value.characters ?? value.classified_characters
245
+ if (Array.isArray(inner)) list = inner
246
+ else list = [value]
247
+ } else {
248
+ throw new AudioGenError(
249
+ 'characters 必须是 JSON 数组、单个对象或 JSON 字符串',
250
+ 'cast-characters-invalid',
251
+ )
252
+ }
253
+
254
+ const profiles: CharacterProfile[] = []
255
+ const missingNames: string[] = []
256
+ list.forEach((item, index) => {
257
+ if (!isRecord(item)) {
258
+ missingNames.push(`characters[${index}] 不是对象`)
259
+ return
260
+ }
261
+ const characterName = asString(item.character_name) || asString(item.name)
262
+ if (characterName === '') {
263
+ missingNames.push(`characters[${index}] 缺少 character_name`)
264
+ return
265
+ }
266
+ const characterId = asString(item.character_id) || slugifyCharacterId(characterName, `r${index}`)
267
+ const rawLines = Array.isArray(item.sample_lines)
268
+ ? item.sample_lines
269
+ : typeof item.sample_lines === 'string'
270
+ ? (item.sample_lines as string).split(/\n+/).map(line => ({ text: line.trim(), emotion_hint: '' }))
271
+ : []
272
+ const sampleLines = rawLines
273
+ .filter(entry => typeof entry === 'string' || isRecord(entry))
274
+ .filter(entry => {
275
+ const text = typeof entry === 'string' ? entry.trim() : (asString(entry.text) || asString(entry.dialogue))
276
+ return text !== ''
277
+ })
278
+ .slice(0, MAX_SAMPLE_LINES)
279
+ .map(entry => {
280
+ const text = typeof entry === 'string' ? entry.trim() : (asString(entry.text) || asString(entry.dialogue))
281
+ const emotion = typeof entry === 'string' ? '' : (asString(entry.emotion_hint) || asString(entry.emotion))
282
+ return { text, ...(emotion === '' ? {} : { emotion_hint: emotion }) }
283
+ })
284
+ profiles.push({
285
+ character_id: characterId,
286
+ character_name: characterName,
287
+ gender: asString(item.gender) || undefined,
288
+ age_stage: asStringList(item.age_stage ?? item.age),
289
+ ...(asString(item.age_stage_source) === '' ? {} : { age_stage_source: asString(item.age_stage_source) }),
290
+ voice_traits: splitTraits(item.voice_traits),
291
+ personality_traits: splitTraits(item.personality_traits),
292
+ appearance: asStringList(item.appearance),
293
+ sample_lines: sampleLines,
294
+ dialogue_count: asNumber(item.dialogue_count),
295
+ importance_tier: asString(item.importance_tier) || asString(item.tier) || undefined,
296
+ ...(asString(item.language) === '' ? {} : { language: asString(item.language) }),
297
+ ...(asString(item.use_case) === '' ? {} : { use_case: asString(item.use_case) }),
298
+ })
299
+ })
300
+
301
+ if (missingNames.length > 0) {
302
+ throw new AudioGenError(
303
+ `characters 数据不完整:${missingNames.slice(0, 5).join(';')}(请为每个角色提供 character_name)`,
304
+ 'cast-character-missing-name',
305
+ )
306
+ }
307
+ if (profiles.length === 0) {
308
+ throw new AudioGenError('characters 为空:请传入至少一个角色画像', 'cast-characters-empty')
309
+ }
310
+ return profiles
311
+ }
312
+
313
+ // ------------------------------------------------------------- mapping
314
+
315
+ /** 性别归一:女/female → female;男/male → male;其余 undefined(不过滤)。 */
316
+ export function mapGender(text: string | undefined): string | undefined {
317
+ const value = (text ?? '').toLowerCase()
318
+ if (value === '') return undefined
319
+ // 注意顺序:'female' 含 'male' 子串,先判女。
320
+ if (value.includes('女') || value.includes('female')) return 'female'
321
+ if (value.includes('男') || value.includes('male')) return 'male'
322
+ return undefined
323
+ }
324
+
325
+ /** 年龄段归一:老/老年→old;中年→middle_aged;少年/少女/青年/成年→young。 */
326
+ export function mapAgeFilter(ageStages: string[] | undefined): string[] {
327
+ const text = (ageStages ?? []).join(' ')
328
+ if (text.trim() === '') return []
329
+ if (text.includes('老') || text.includes('老年')) return ['old']
330
+ if (text.includes('中年') || text.includes('middle')) return ['middle_aged']
331
+ if (text.includes('少年') || text.includes('少女') || text.includes('少男')
332
+ || text.includes('儿童') || text.includes('幼年') || text.includes('teen')
333
+ || text.includes('child') || text.includes('young')) return ['young']
334
+ if (text.includes('青年') || text.includes('年轻') || text.includes('成年')
335
+ || text.includes('youth') || text.includes('adult')) return ['young']
336
+ return []
337
+ }
338
+
339
+ // ------------------------------------------------------------- filter
340
+
341
+ /** 一个角色对候选池的硬过滤(gender/age/use_case 严格,accent 可放松)。 */
342
+ export function filterCandidatesWithFallback(
343
+ pool: VendorVoiceEntry[],
344
+ options: {
345
+ gender?: string
346
+ ages?: string[]
347
+ accent?: string
348
+ use_case?: string
349
+ language?: string
350
+ },
351
+ ): { candidates: VendorVoiceEntry[]; relaxedAccent: boolean; notes: string } {
352
+ const gender = options.gender
353
+ const ages = options.ages ?? []
354
+ const accent = options.accent
355
+ const useCase = options.use_case
356
+ const language = options.language
357
+
358
+ const matches = (voice: VendorVoiceEntry, checkAccent: boolean): boolean => {
359
+ if (gender !== undefined && (voice.gender ?? '').toLowerCase() !== gender.toLowerCase()) return false
360
+ if (ages.length > 0 && !ages.includes((voice.age ?? '').toLowerCase())) return false
361
+ if (language !== undefined && !languageMatchesEntry(voice, language)) return false
362
+ if (useCase !== undefined && useCase !== '' && (voice.use_case ?? '').toLowerCase() !== useCase.toLowerCase()) return false
363
+ if (checkAccent && accent !== undefined && accent !== '' && (voice.accent ?? '').toLowerCase() !== accent.toLowerCase()) return false
364
+ return true
365
+ }
366
+
367
+ const strict = pool.filter(voice => matches(voice, true))
368
+ const applied: string[] = []
369
+ if (gender !== undefined) applied.push(`gender=${gender}`)
370
+ if (ages.length > 0) applied.push(`age=${ages.join('/')}`)
371
+ if (useCase !== undefined && useCase !== '') applied.push(`use_case=${useCase}`)
372
+ if (accent !== undefined && accent !== '') applied.push('accent')
373
+ if (language !== undefined && language !== '') applied.push(`language=${language}`)
374
+ const appliedText = applied.join('+') === '' ? 'none' : applied.join('+')
375
+ if (strict.length > 0) {
376
+ return { candidates: strict, relaxedAccent: false, notes: `candidate_filter=${appliedText}` }
377
+ }
378
+ const relaxed = pool.filter(voice => matches(voice, false))
379
+ if (relaxed.length > 0) {
380
+ return { candidates: relaxed, relaxedAccent: true, notes: `candidate_filter=${appliedText};accent 已放松` }
381
+ }
382
+ return { candidates: [], relaxedAccent: false, notes: 'candidate_filter=empty;严格过滤后无候选(可放宽 use_case 或换渠道)' }
383
+ }
384
+
385
+ function languageMatchesEntry(voice: VendorVoiceEntry, needle: string): boolean {
386
+ const haystack = [voice.language ?? '', voice.locale ?? '', voice.name ?? ''].join(' ').toLowerCase()
387
+ const value = needle.trim().toLowerCase()
388
+ if (value === '') return true
389
+ if (haystack.includes(value)) return true
390
+ const aliases: Record<string, string[]> = {
391
+ zh: ['zh', 'chinese', 'mandarin', 'cantonese'],
392
+ en: ['en', 'english'],
393
+ ja: ['ja', 'japanese'],
394
+ ko: ['ko', 'korean'],
395
+ es: ['es', 'spanish'],
396
+ fr: ['fr', 'french'],
397
+ de: ['de', 'german'],
398
+ ru: ['ru', 'russian'],
399
+ it: ['it', 'italian'],
400
+ pt: ['pt', 'portuguese'],
401
+ ar: ['ar', 'arabic'],
402
+ hi: ['hi', 'hindi'],
403
+ }
404
+ for (const alias of aliases[value] ?? []) {
405
+ if (haystack.includes(alias)) return true
406
+ }
407
+ return false
408
+ }
409
+
410
+ function toSlimVoice(voice: VendorVoiceEntry): SlimVoice {
411
+ const trimmed = voice.description !== undefined && voice.description.length > MAX_DESCRIPTION_CHARS
412
+ ? `${voice.description.slice(0, MAX_DESCRIPTION_CHARS - 1)}…`
413
+ : voice.description
414
+ return {
415
+ voice_id: voice.voice_id,
416
+ name: voice.name,
417
+ source: voice.source,
418
+ deletable: voice.deletable,
419
+ ...(voice.language === undefined ? {} : { language: voice.language }),
420
+ ...(voice.locale === undefined ? {} : { locale: voice.locale }),
421
+ ...(voice.accent === undefined ? {} : { accent: voice.accent }),
422
+ ...(voice.gender === undefined ? {} : { gender: voice.gender }),
423
+ ...(voice.age === undefined ? {} : { age: voice.age }),
424
+ ...(voice.use_case === undefined ? {} : { use_case: voice.use_case }),
425
+ ...(voice.category === undefined ? {} : { category: voice.category }),
426
+ ...(voice.labels === undefined ? {} : { labels: voice.labels }),
427
+ ...(voice.descriptive === undefined ? {} : { descriptive: voice.descriptive }),
428
+ ...(trimmed === undefined ? {} : { description: trimmed }),
429
+ ...(voice.preview_url === undefined ? {} : { preview_url: voice.preview_url }),
430
+ }
431
+ }
432
+
433
+ // ------------------------------------------------------------ fetch pool
434
+
435
+ export interface VoiceCastOptions {
436
+ /** 角色语言(ISO 或 MiniMax 前缀);ElevenLabs 候选池按此本地过滤。 */
437
+ language?: string
438
+ /** 硬过滤用途(ElevenLabs characters_animation 等);传 '' 表示不限制。 */
439
+ use_case?: string
440
+ /** accent 为偏好:严格候选为空时自动放松,不参与硬过滤。 */
441
+ accent?: string
442
+ }
443
+
444
+ async function fetchPool(channel: AudioChannel, options: VoiceCastOptions): Promise<{
445
+ vendor: string
446
+ pool: VendorVoiceEntry[]
447
+ note?: string
448
+ }> {
449
+ if (channel.apiUrl.trim() === '') throw new AudioGenError('渠道未配置 API 地址', 'audio-api-not-configured')
450
+ if (channel.apiKey.trim() === '') throw new AudioGenError('渠道未配置 API 密钥', 'audio-api-not-configured')
451
+ const useCase = options.use_case !== undefined && options.use_case !== '' ? options.use_case : undefined
452
+ const result = await listVendorVoicesWithFallback(channel, {
453
+ limit: 500,
454
+ ...(options.language !== undefined && options.language !== '' ? { language: options.language } : {}),
455
+ // accent 不预筛:本地按角色分别「严格 → 放松」处理,才能保留可放松的池子。
456
+ ...(useCase === undefined ? {} : { serverFilters: { use_case: useCase } }),
457
+ })
458
+ return { vendor: result.vendor, pool: result.voices, ...(result.note === undefined ? {} : { note: result.note }) }
459
+ }
460
+
461
+ // ---------------------------------------------------------------- prepare
462
+
463
+ /** action=cast:角色画像 → 每个角色的硬过滤候选列表(不选角色,不调 LLM)。 */
464
+ export async function prepareVoiceCast(
465
+ channel: AudioChannel,
466
+ profiles: CharacterProfile[],
467
+ options: VoiceCastOptions = {},
468
+ ): Promise<{
469
+ vendor: string
470
+ channel: string
471
+ pool_size: number
472
+ use_case_filter: string
473
+ accent_preference: string
474
+ character_count: number
475
+ characters: CastCharacterView[]
476
+ note?: string
477
+ }> {
478
+ const { vendor, pool, note } = await fetchPool(channel, options)
479
+ // MiniMax 音色没有性别/年龄/用途元数据(system voice_id 只有语言前缀),
480
+ // 不能按元数据硬过滤;ElevenLabs 则严格按元数据排除(与 standalone 行为一致)。
481
+ // 网关渠道兜底池(source=configured)同样无元数据:一律跳过过滤。
482
+ const fallbackPool = pool.some(entry => entry.source === 'configured')
483
+ const hasMetadata = vendor !== 'minimax' && !fallbackPool
484
+ const views: CastCharacterView[] = []
485
+ for (const profile of profiles) {
486
+ const gender = hasMetadata ? mapGender(profile.gender) : undefined
487
+ const ages = hasMetadata ? mapAgeFilter(profile.age_stage) : []
488
+ const language = hasMetadata ? (profile.language ?? options.language) : undefined
489
+ const useCase = hasMetadata ? (profile.use_case ?? options.use_case) : undefined
490
+ const accent = hasMetadata ? options.accent : undefined
491
+ const dialogueCount = profile.dialogue_count ?? 0
492
+ const tier = importanceTier(dialogueCount, profile.importance_tier)
493
+
494
+ const filtered = filterCandidatesWithFallback(pool, {
495
+ ...(gender === undefined ? {} : { gender }),
496
+ ...(ages.length > 0 ? { ages } : {}),
497
+ ...(accent === undefined || accent === '' ? {} : { accent }),
498
+ ...(useCase === undefined || useCase === '' ? {} : { use_case: useCase }),
499
+ ...(language === undefined || language === '' ? {} : { language }),
500
+ })
501
+ const candidates = filtered.candidates.slice(0, MAX_CANDIDATES_PER_CHARACTER)
502
+
503
+ const mapped: CastCharacterView['mapped_filters'] = {
504
+ ...(gender === undefined ? {} : { gender }),
505
+ ...(ages.length > 0 ? { age: ages } : {}),
506
+ ...(accent === undefined || accent === '' ? {} : { accent }),
507
+ ...(useCase === undefined || useCase === '' ? {} : { use_case: useCase }),
508
+ ...(language === undefined || language === '' ? {} : { language }),
509
+ notes: `age_stage=${(profile.age_stage ?? []).join('/') || 'unknown'}; ${filtered.notes}${vendor === 'minimax' ? ';MiniMax 音色无性别/年龄/用途元数据,实际仅按语言与名称/描述筛选' : ''}`,
510
+ }
511
+
512
+ const slim: CastCharacterSlim = {
513
+ character_id: profile.character_id,
514
+ character_name: profile.character_name,
515
+ dialogue_count: dialogueCount,
516
+ importance_tier: tier,
517
+ }
518
+ if (profile.gender !== undefined) slim.gender = profile.gender
519
+ if ((profile.age_stage ?? []).length > 0) slim.age_stage = profile.age_stage
520
+ if (profile.age_stage_source !== undefined) slim.age_stage_source = profile.age_stage_source
521
+ if ((profile.voice_traits ?? []).length > 0) slim.voice_traits = profile.voice_traits
522
+ if ((profile.personality_traits ?? []).length > 0) slim.personality_traits = profile.personality_traits
523
+ if ((profile.appearance ?? []).length > 0) slim.appearance = profile.appearance
524
+ if ((profile.sample_lines ?? []).length > 0) slim.sample_lines = profile.sample_lines
525
+ if (profile.language !== undefined) slim.language = profile.language
526
+ if (profile.use_case !== undefined) slim.use_case = profile.use_case
527
+
528
+ views.push({
529
+ character: slim,
530
+ mapped_filters: mapped,
531
+ candidate_count: candidates.length,
532
+ candidate_voices: candidates.map(toSlimVoice),
533
+ ...(filtered.candidates.length > candidates.length
534
+ ? { note: `candidates truncated: ${filtered.candidates.length} → ${candidates.length}` }
535
+ : {}),
536
+ })
537
+ }
538
+ return {
539
+ vendor,
540
+ channel: channel.name,
541
+ pool_size: pool.length,
542
+ use_case_filter: options.use_case ?? '',
543
+ accent_preference: options.accent ?? '',
544
+ character_count: profiles.length,
545
+ characters: views,
546
+ ...(note === undefined ? {} : { note }),
547
+ }
548
+ }
549
+
550
+ // ---------------------------------------------------------------- save
551
+
552
+ /** action=save_cast:Agent 选角结果 → 校验 + 补齐 + 落盘(选定记录)。 */
553
+ export async function saveVoiceCast(
554
+ channel: AudioChannel,
555
+ profiles: CharacterProfile[],
556
+ selectionInputs: CastSelectionInput[],
557
+ options: VoiceCastOptions = {},
558
+ ): Promise<{
559
+ vendor: string
560
+ channel: string
561
+ store_path: string
562
+ count: number
563
+ entries: CastSelectionRecord[]
564
+ issues: CastIssue[]
565
+ }> {
566
+ const prepared = await prepareVoiceCast(channel, profiles, options)
567
+ const viewsByCharacter = new Map(prepared.characters.map(view => [String(view.character.character_id), view]))
568
+ const inputByCharacter = new Map<string, CastSelectionInput>()
569
+ for (const input of selectionInputs) {
570
+ if (input === null || typeof input !== 'object') continue
571
+ const characterId = asString(input.character_id)
572
+ if (characterId !== '') inputByCharacter.set(characterId, input)
573
+ }
574
+
575
+ const entries: CastSelectionRecord[] = []
576
+ const issues: CastIssue[] = []
577
+ const sawReuse = new Map<string, Array<{ character_id: string; character_name: string; tier: string }>>()
578
+
579
+ for (const view of prepared.characters) {
580
+ const characterId = String(view.character.character_id ?? '')
581
+ const characterName = String(view.character.character_name ?? characterId)
582
+ const candidates = view.candidate_voices
583
+ const input = inputByCharacter.get(characterId)
584
+ const dialogueCount = asNumber(view.character.dialogue_count) ?? 0
585
+ const tier = String(view.character.importance_tier ?? importanceTier(dialogueCount))
586
+
587
+ let primary = asString(input?.voice_id ?? '')
588
+ let status = 'ok'
589
+ const recordIssues: string[] = []
590
+ if (primary === '' || !candidates.some(voice => voice.voice_id === primary)) {
591
+ const fallback = candidates[0]?.voice_id ?? ''
592
+ if (fallback !== '') {
593
+ recordIssues.push('voice_id_not_in_candidates; fallback=first_candidate')
594
+ primary = fallback
595
+ status = 'tool_fallback'
596
+ }
597
+ }
598
+
599
+ const requiredBackupCount = Math.min(BACKUP_LIMIT, Math.max(candidates.length - 1, 0))
600
+ const backups: string[] = []
601
+ const rawBackups = Array.isArray(input?.backup_voice_ids) ? input.backup_voice_ids : []
602
+ for (const raw of rawBackups) {
603
+ const id = asString(raw)
604
+ if (id === '' || id === primary || backups.includes(id)) continue
605
+ if (!candidates.some(voice => voice.voice_id === id)) continue
606
+ backups.push(id)
607
+ if (backups.length >= requiredBackupCount) break
608
+ }
609
+ if (backups.length < requiredBackupCount) {
610
+ for (const voice of candidates) {
611
+ if (backups.length >= requiredBackupCount) break
612
+ if (voice.voice_id === primary || backups.includes(voice.voice_id)) continue
613
+ backups.push(voice.voice_id)
614
+ }
615
+ if (status === 'ok') status = 'fixed'
616
+ recordIssues.push('backup_voice_ids_auto_filled')
617
+ }
618
+
619
+ const primaryVoice = candidates.find(voice => voice.voice_id === primary)
620
+ const reason = asString(input?.reason ?? '')
621
+ const record: CastSelectionRecord = {
622
+ character_id: characterId,
623
+ character_name: asString(input?.character_name) || characterName,
624
+ voice_id: primary,
625
+ voice_name: primaryVoice?.name ?? '',
626
+ backup_voice_ids: backups,
627
+ reason: reason === ''
628
+ ? (status === 'tool_fallback' ? 'tool fallback: selected from filtered candidates' : '')
629
+ : reason,
630
+ dialogue_count: dialogueCount,
631
+ importance_tier: tier,
632
+ selection_status: status,
633
+ issues: recordIssues,
634
+ selected_at: new Date().toISOString(),
635
+ }
636
+ entries.push(record)
637
+ sawReuse.set(primary, [...(sawReuse.get(primary) ?? []), { character_id: characterId, character_name: characterName, tier }])
638
+ }
639
+
640
+ // lead/major 主音色复用检查:仅记录警告,由 Agent 在下一轮修正。
641
+ for (const [voiceId, users] of sawReuse) {
642
+ const important = users.filter(user => user.tier === 'lead' || user.tier === 'major')
643
+ if (important.length > 1) {
644
+ for (const user of important) {
645
+ issues.push({
646
+ character_id: user.character_id,
647
+ character_name: user.character_name,
648
+ issue: 'primary_voice_reused',
649
+ detail: `主音色 ${voiceId} 被 ${important.map(item => item.character_name).join('、')} 复用;lead/major 角色应尽量使用不同主音色`,
650
+ })
651
+ }
652
+ }
653
+ }
654
+
655
+ const storePath = await writeCastSelections(channel, entries)
656
+ return { vendor: prepared.vendor, channel: channel.name, store_path: storePath, count: entries.length, entries, issues }
657
+ }
658
+
659
+ // ------------------------------------------------------------ store
660
+
661
+ export function castSelectionsPath(): string {
662
+ return path.join(process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh'), 'dsh-audiogen', 'cast-selections.json')
663
+ }
664
+
665
+ interface CastSelectionsStore {
666
+ version: number
667
+ updatedAt: string
668
+ channels: Record<string, {
669
+ provider: string
670
+ name: string
671
+ updatedAt: string
672
+ entries: Record<string, CastSelectionRecord>
673
+ }>
674
+ }
675
+
676
+ async function loadCastSelections(): Promise<CastSelectionsStore> {
677
+ try {
678
+ const text = await readFile(castSelectionsPath(), 'utf-8')
679
+ const payload = JSON.parse(text)
680
+ if (typeof payload === 'object' && payload !== null && typeof (payload as Record<string, unknown>).channels === 'object') {
681
+ return payload as CastSelectionsStore
682
+ }
683
+ } catch {
684
+ // 文件不存在或损坏时重建。
685
+ }
686
+ return { version: 1, updatedAt: new Date().toISOString(), channels: {} }
687
+ }
688
+
689
+ /** 按渠道合并选定记录(同 character_id 覆盖),写回本地 JSON。 */
690
+ export async function writeCastSelections(
691
+ channel: AudioChannel,
692
+ records: CastSelectionRecord[],
693
+ ): Promise<string> {
694
+ const store = await loadCastSelections()
695
+ const bucket = store.channels[channel.id] ?? { provider: channel.preset || 'custom', name: channel.name, updatedAt: '', entries: {} }
696
+ for (const record of records) {
697
+ bucket.entries[record.character_id] = record
698
+ }
699
+ bucket.updatedAt = new Date().toISOString()
700
+ store.channels[channel.id] = bucket
701
+ store.updatedAt = new Date().toISOString()
702
+ const file = castSelectionsPath()
703
+ await mkdir(path.dirname(file), { recursive: true })
704
+ await writeFile(file, JSON.stringify(store, null, 2) + '\n', 'utf-8')
705
+ return file
706
+ }
707
+
708
+ /** 读取某渠道已保存的选定记录(character_id → 记录)。 */
709
+ export async function readStoredCastSelections(channelId: string): Promise<Record<string, CastSelectionRecord>> {
710
+ const store = await loadCastSelections()
711
+ return store.channels[channelId]?.entries ?? {}
712
+ }