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
|
@@ -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
|
+
}
|