dsh-audiogen 0.4.13 → 0.4.15
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 +2 -1
- package/README.zh-CN.md +2 -1
- package/lib/client.js +350 -348
- package/lib/client.js.map +1 -1
- package/lib/index.js +588 -27
- package/package.json +1 -1
- package/skills/sfx/SKILL.md +6 -0
- package/skills/voice-management/SKILL.md +48 -0
- package/src/agent-audio-tools.ts +123 -0
- package/src/audio-engine.ts +170 -10
- package/src/client/api.ts +2 -0
- package/src/client/audio-panel.module.css +7 -0
- package/src/client/field-specs.ts +1 -1
- package/src/client/studio-view.tsx +9 -1
- package/src/index.ts +1 -1
- package/src/protocol.ts +1 -1
- package/src/routes.ts +6 -1
- package/src/voice-manager.ts +378 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side vendor voice management.
|
|
3
|
+
*
|
|
4
|
+
* Browse/filter the TTS voices a provider exposes and delete account-owned
|
|
5
|
+
* voices. Agent-facing through the `manage_audio_voices` tool; the settings
|
|
6
|
+
* card uses `discoverAudioModels` for its (broader, model-grouped) detection.
|
|
7
|
+
*
|
|
8
|
+
* Vendor endpoints:
|
|
9
|
+
* - MiniMax POST /v1/get_voice (voice_type=all -> system_voice /
|
|
10
|
+
* voice_cloning / voice_generation)
|
|
11
|
+
* POST /v1/delete_voice (voice_type=voice_cloning)
|
|
12
|
+
* - ElevenLabs GET /v1/voices (owned) + /v1/shared-voices (community)
|
|
13
|
+
* DELETE /v1/voices/{voice_id} (owned only)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AudioChannel } from './audio-engine.ts'
|
|
17
|
+
|
|
18
|
+
/** One normalized vendor voice entry (mirrors the Python module contract). */
|
|
19
|
+
export interface VendorVoiceEntry {
|
|
20
|
+
provider: string
|
|
21
|
+
voice_id: string
|
|
22
|
+
name: string
|
|
23
|
+
/** system | custom | owned | shared — determines deletability. */
|
|
24
|
+
source: 'system' | 'custom' | 'owned' | 'shared'
|
|
25
|
+
language?: string
|
|
26
|
+
locale?: string
|
|
27
|
+
accent?: string
|
|
28
|
+
gender?: string
|
|
29
|
+
age?: string
|
|
30
|
+
use_case?: string
|
|
31
|
+
category?: string
|
|
32
|
+
description?: string
|
|
33
|
+
preview_url?: string
|
|
34
|
+
/** Whether deleteVendorVoice would accept this voice (custom/owned only). */
|
|
35
|
+
deletable: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ListVoicesOptions {
|
|
39
|
+
/** Free-text filter over name/description/accent/use_case (case-insensitive). */
|
|
40
|
+
keyword?: string
|
|
41
|
+
/** Substring filter over language/locale (e.g. "en", "Chinese"). */
|
|
42
|
+
language?: string
|
|
43
|
+
/** Filter by entry source: system/custom/owned/shared. */
|
|
44
|
+
source?: string
|
|
45
|
+
/** Hard cap on returned entries (default 100). */
|
|
46
|
+
limit?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ListVoicesResult {
|
|
50
|
+
vendor: string
|
|
51
|
+
voices: VendorVoiceEntry[]
|
|
52
|
+
truncated: boolean
|
|
53
|
+
/** Human note when something was skipped (e.g. shared-voice endpoint failed). */
|
|
54
|
+
note?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const MINIMAX_LANGUAGE_PREFIXES = [
|
|
58
|
+
'Chinese (Mandarin)', 'Chinese (Cantonese)', 'Japanese', 'English', 'Korean',
|
|
59
|
+
'Spanish', 'French', 'German', 'Italian', 'Russian', 'Portuguese', 'Arabic',
|
|
60
|
+
'Hindi',
|
|
61
|
+
] as const
|
|
62
|
+
|
|
63
|
+
export function isMiniMax(channel: AudioChannel): boolean {
|
|
64
|
+
return channel.preset === 'minimax' || /minimax/i.test(channel.apiUrl)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isElevenLabs(channel: AudioChannel): boolean {
|
|
68
|
+
return channel.preset === 'elevenlabs' || /elevenlabs/i.test(channel.apiUrl)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function supportsVoiceManagement(channel: AudioChannel): boolean {
|
|
72
|
+
return isMiniMax(channel) || isElevenLabs(channel)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function baseUrl(url: string): string {
|
|
76
|
+
return url.trim().replace(/\/+$/, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** MiniMax system voice ids carry a language label prefix. */
|
|
80
|
+
export function languageFromMiniMaxId(voiceId: string): string | undefined {
|
|
81
|
+
for (const prefix of MINIMAX_LANGUAGE_PREFIXES) {
|
|
82
|
+
if (voiceId.startsWith(`${prefix}_`)) return prefix
|
|
83
|
+
}
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function asStringList(value: unknown): string[] {
|
|
88
|
+
if (Array.isArray(value)) return value.map(item => String(item)).filter(item => item.trim() !== '')
|
|
89
|
+
if (typeof value === 'string' && value.trim() !== '') return [value.trim()]
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ----------------------------------------------------------------- fetch
|
|
94
|
+
|
|
95
|
+
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
|
96
|
+
const response = await fetch(url, init)
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const text = await response.text().catch(() => '')
|
|
99
|
+
throw new Error(`HTTP ${response.status}${text === '' ? '' : `: ${text.slice(0, 300)}`}`)
|
|
100
|
+
}
|
|
101
|
+
return response.json()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function postJson(url: string, apiKey: string, body: unknown): Promise<unknown> {
|
|
105
|
+
return fetchJson(url, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: {
|
|
108
|
+
authorization: `Bearer ${apiKey.trim()}`,
|
|
109
|
+
'content-type': 'application/json',
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ------------------------------------------------------------- MiniMax
|
|
116
|
+
|
|
117
|
+
function normalizeMiniMax(voice: Record<string, unknown>, source: 'system' | 'custom'): VendorVoiceEntry {
|
|
118
|
+
const voiceId = String(voice.voice_id ?? '').trim()
|
|
119
|
+
const description = asStringList(voice.description).join(';') || undefined
|
|
120
|
+
const language = languageFromMiniMaxId(voiceId)
|
|
121
|
+
return {
|
|
122
|
+
provider: 'minimax',
|
|
123
|
+
voice_id: voiceId,
|
|
124
|
+
name: String(voice.voice_name ?? voiceId).trim() || voiceId,
|
|
125
|
+
source,
|
|
126
|
+
...(language === undefined ? {} : { language }),
|
|
127
|
+
...(description === undefined ? {} : { description }),
|
|
128
|
+
deletable: source === 'custom',
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function listMiniMax(channel: AudioChannel): Promise<ListVoicesResult> {
|
|
133
|
+
const base = baseUrl(channel.apiUrl).replace(/\/v1$/i, '')
|
|
134
|
+
const payload = await postJson(`${base}/v1/get_voice`, channel.apiKey, { voice_type: 'all' }) as {
|
|
135
|
+
system_voice?: unknown[]
|
|
136
|
+
voice_cloning?: unknown[]
|
|
137
|
+
voice_generation?: unknown[]
|
|
138
|
+
base_resp?: { status_code?: number; status_msg?: string }
|
|
139
|
+
}
|
|
140
|
+
if (payload.base_resp?.status_code !== undefined && payload.base_resp.status_code !== 0) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`MiniMax get_voice 失败:${payload.base_resp.status_msg ?? `status ${payload.base_resp.status_code}`}`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
const entries: VendorVoiceEntry[] = []
|
|
146
|
+
for (const voice of Array.isArray(payload.system_voice) ? payload.system_voice : []) {
|
|
147
|
+
if (typeof voice !== 'object' || voice === null) continue
|
|
148
|
+
const entry = normalizeMiniMax(voice as Record<string, unknown>, 'system')
|
|
149
|
+
if (entry.voice_id !== '') entries.push(entry)
|
|
150
|
+
}
|
|
151
|
+
for (const bucket of ['voice_cloning', 'voice_generation'] as const) {
|
|
152
|
+
for (const voice of Array.isArray(payload[bucket]) ? payload[bucket] : []) {
|
|
153
|
+
if (typeof voice !== 'object' || voice === null) continue
|
|
154
|
+
const entry = normalizeMiniMax(voice as Record<string, unknown>, 'custom')
|
|
155
|
+
if (entry.voice_id !== '') entries.push(entry)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { vendor: 'minimax', voices: entries, truncated: false }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------- ElevenLabs
|
|
162
|
+
|
|
163
|
+
function normalizeElevenLabs(
|
|
164
|
+
voice: Record<string, unknown>,
|
|
165
|
+
source: 'owned' | 'shared',
|
|
166
|
+
): VendorVoiceEntry {
|
|
167
|
+
const voiceId = String(voice.voice_id ?? '').trim()
|
|
168
|
+
const labels = typeof voice.labels === 'object' && voice.labels !== null
|
|
169
|
+
? (voice.labels as Record<string, unknown>)
|
|
170
|
+
: undefined
|
|
171
|
+
const pick = (key: string): string | undefined => {
|
|
172
|
+
const owned = labels?.[key]
|
|
173
|
+
const direct = voice[key]
|
|
174
|
+
const value = typeof owned === 'string' && owned.trim() !== '' ? owned : typeof direct === 'string' && direct.trim() !== '' ? direct : undefined
|
|
175
|
+
return value?.trim() || undefined
|
|
176
|
+
}
|
|
177
|
+
const description = pick('description')
|
|
178
|
+
const name = String(voice.name ?? voiceId).trim() || voiceId
|
|
179
|
+
return {
|
|
180
|
+
provider: 'elevenlabs',
|
|
181
|
+
voice_id: voiceId,
|
|
182
|
+
name,
|
|
183
|
+
source,
|
|
184
|
+
...(pick('language') === undefined ? {} : { language: pick('language')! }),
|
|
185
|
+
...(pick('locale') === undefined ? {} : { locale: pick('locale')! }),
|
|
186
|
+
...(pick('accent') === undefined ? {} : { accent: pick('accent')! }),
|
|
187
|
+
...(pick('gender') === undefined ? {} : { gender: pick('gender')! }),
|
|
188
|
+
...(pick('age') === undefined ? {} : { age: pick('age')! }),
|
|
189
|
+
...(pick('use_case') === undefined ? {} : { use_case: pick('use_case')! }),
|
|
190
|
+
...(pick('category') === undefined ? {} : { category: pick('category')! }),
|
|
191
|
+
...(description === undefined ? {} : { description }),
|
|
192
|
+
...(typeof voice.preview_url === 'string' && voice.preview_url.trim() !== '' ? { preview_url: voice.preview_url.trim() } : {}),
|
|
193
|
+
deletable: source === 'owned',
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function listElevenLabs(channel: AudioChannel, options: ListVoicesOptions): Promise<ListVoicesResult> {
|
|
198
|
+
const base = baseUrl(channel.apiUrl)
|
|
199
|
+
const headers = { 'xi-api-key': channel.apiKey.trim(), accept: 'application/json' }
|
|
200
|
+
const entries: VendorVoiceEntry[] = []
|
|
201
|
+
const failures: string[] = []
|
|
202
|
+
|
|
203
|
+
// 1. Account-owned voices (/v1/voices) — deletable.
|
|
204
|
+
try {
|
|
205
|
+
const payload = await fetchJson(`${base}/voices`, { headers }) as { voices?: unknown[] }
|
|
206
|
+
for (const voice of Array.isArray(payload.voices) ? payload.voices : []) {
|
|
207
|
+
if (typeof voice !== 'object' || voice === null) continue
|
|
208
|
+
const entry = normalizeElevenLabs(voice as Record<string, unknown>, 'owned')
|
|
209
|
+
if (entry.voice_id !== '') entries.push(entry)
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
failures.push(`自有音色:${error instanceof Error ? error.message : String(error)}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 2. Community library (/v1/shared-voices, up to 3 pages) — read-only.
|
|
216
|
+
const pageSize = 100
|
|
217
|
+
try {
|
|
218
|
+
for (let page = 0; page < 3; page += 1) {
|
|
219
|
+
const query = new URLSearchParams({ page_size: String(pageSize), page: String(page) })
|
|
220
|
+
if (options.language !== undefined && options.language.trim() !== '') query.set('language', options.language.trim())
|
|
221
|
+
const payload = await fetchJson(`${base}/shared-voices?${query.toString()}`, { headers }) as {
|
|
222
|
+
voices?: unknown[]
|
|
223
|
+
has_more?: boolean
|
|
224
|
+
}
|
|
225
|
+
const voices = Array.isArray(payload.voices) ? payload.voices : []
|
|
226
|
+
for (const voice of voices) {
|
|
227
|
+
if (typeof voice !== 'object' || voice === null) continue
|
|
228
|
+
const entry = normalizeElevenLabs(voice as Record<string, unknown>, 'shared')
|
|
229
|
+
if (entry.voice_id !== '') entries.push(entry)
|
|
230
|
+
}
|
|
231
|
+
if (payload.has_more !== true || voices.length === 0) break
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
failures.push(`共享音色库:${error instanceof Error ? error.message : String(error)}`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (entries.length === 0 && failures.length > 0) {
|
|
238
|
+
throw new Error(`ElevenLabs 音色列表拉取失败:${failures.join(';').slice(0, 300)}`)
|
|
239
|
+
}
|
|
240
|
+
// Owned voices come first and shadow shared ones with the same id.
|
|
241
|
+
const seen = new Set<string>()
|
|
242
|
+
const deduped: VendorVoiceEntry[] = []
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
if (seen.has(entry.voice_id)) continue
|
|
245
|
+
seen.add(entry.voice_id)
|
|
246
|
+
deduped.push(entry)
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
vendor: 'elevenlabs',
|
|
250
|
+
voices: deduped,
|
|
251
|
+
truncated: false,
|
|
252
|
+
...(failures.length === 0 ? {} : { note: `部分端点失败(已忽略):${failures.join(';').slice(0, 300)}` }),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ------------------------------------------------------ public surface
|
|
257
|
+
|
|
258
|
+
export async function listVendorVoices(
|
|
259
|
+
channel: AudioChannel,
|
|
260
|
+
options: ListVoicesOptions = {},
|
|
261
|
+
): Promise<ListVoicesResult> {
|
|
262
|
+
if (channel.apiUrl.trim() === '') throw new Error('渠道未配置 API 地址')
|
|
263
|
+
if (channel.apiKey.trim() === '') throw new Error('渠道未配置 API 密钥')
|
|
264
|
+
if (!supportsVoiceManagement(channel)) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`当前渠道「${channel.name}」不提供厂商音色管理接口:仅 MiniMax 与 ElevenLabs 支持音色浏览/删除`,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
if (isMiniMax(channel)) {
|
|
270
|
+
const result = await listMiniMax(channel)
|
|
271
|
+
return {
|
|
272
|
+
vendor: result.vendor,
|
|
273
|
+
...applyFilter(result.voices, options),
|
|
274
|
+
...(result.note === undefined ? {} : { note: result.note }),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const result = await listElevenLabs(channel, options)
|
|
278
|
+
return {
|
|
279
|
+
vendor: result.vendor,
|
|
280
|
+
...applyFilter(result.voices, options),
|
|
281
|
+
...(result.note === undefined ? {} : { note: result.note }),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function deleteVendorVoice(channel: AudioChannel, voiceId: string): Promise<{
|
|
286
|
+
vendor: string
|
|
287
|
+
voice_id: string
|
|
288
|
+
deleted: true
|
|
289
|
+
}> {
|
|
290
|
+
if (channel.apiUrl.trim() === '') throw new Error('渠道未配置 API 地址')
|
|
291
|
+
if (channel.apiKey.trim() === '') throw new Error('渠道未配置 API 密钥')
|
|
292
|
+
const id = voiceId.trim()
|
|
293
|
+
if (id === '') throw new Error('voice_id 不能为空')
|
|
294
|
+
|
|
295
|
+
if (isMiniMax(channel)) {
|
|
296
|
+
// Guard: only custom voices (voice_cloning/voice_generation) are deletable.
|
|
297
|
+
const listed = await listMiniMax(channel)
|
|
298
|
+
const known = listed.voices.find(entry => entry.voice_id === id)
|
|
299
|
+
if (known !== undefined && known.source === 'system') {
|
|
300
|
+
throw new Error(`MiniMax 系统预置音色「${id}」为只读,不能删除(仅自定义音色可删)`)
|
|
301
|
+
}
|
|
302
|
+
const base = baseUrl(channel.apiUrl).replace(/\/v1$/i, '')
|
|
303
|
+
const payload = await postJson(`${base}/v1/delete_voice`, channel.apiKey, {
|
|
304
|
+
voice_id: id,
|
|
305
|
+
voice_type: 'voice_cloning',
|
|
306
|
+
}) as { base_resp?: { status_code?: number; status_msg?: string } }
|
|
307
|
+
if (payload.base_resp?.status_code !== undefined && payload.base_resp.status_code !== 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`MiniMax delete_voice 失败:${payload.base_resp.status_msg ?? `status ${payload.base_resp.status_code}`}`,
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
return { vendor: 'minimax', voice_id: id, deleted: true }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (isElevenLabs(channel)) {
|
|
316
|
+
const base = baseUrl(channel.apiUrl)
|
|
317
|
+
const headers = { 'xi-api-key': channel.apiKey.trim(), accept: 'application/json' }
|
|
318
|
+
// Guard: only account-owned voices are deletable; community voices cannot.
|
|
319
|
+
let owned = false
|
|
320
|
+
let checkFailed = false
|
|
321
|
+
try {
|
|
322
|
+
const payload = await fetchJson(`${base}/voices`, { headers }) as { voices?: Array<{ voice_id?: string }> }
|
|
323
|
+
if (Array.isArray(payload.voices)) {
|
|
324
|
+
owned = payload.voices.some(voice => String(voice.voice_id ?? '') === id)
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
checkFailed = true
|
|
328
|
+
}
|
|
329
|
+
if (!checkFailed && !owned) {
|
|
330
|
+
throw new Error(`ElevenLabs 音色「${id}」不是账户自有音色(共享库/官方音色只读),不能删除`)
|
|
331
|
+
}
|
|
332
|
+
const response = await fetch(`${base}/voices/${encodeURIComponent(id)}`, { method: 'DELETE', headers })
|
|
333
|
+
if (!response.ok) {
|
|
334
|
+
const text = await response.text().catch(() => '')
|
|
335
|
+
throw new Error(`ElevenLabs 删除失败(HTTP ${response.status})${text === '' ? '' : `:${text.slice(0, 300)}`}`)
|
|
336
|
+
}
|
|
337
|
+
return { vendor: 'elevenlabs', voice_id: id, deleted: true }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
throw new Error(
|
|
341
|
+
`当前渠道「${channel.name}」不提供厂商音色管理接口:仅 MiniMax 与 ElevenLabs 支持音色浏览/删除`,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------- pure
|
|
346
|
+
|
|
347
|
+
function cap(options: ListVoicesOptions): number {
|
|
348
|
+
const limit = typeof options.limit === 'number' && Number.isFinite(options.limit)
|
|
349
|
+
? Math.floor(options.limit)
|
|
350
|
+
: 100
|
|
351
|
+
return Math.max(1, Math.min(200, limit))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function applyFilter(
|
|
355
|
+
entries: VendorVoiceEntry[],
|
|
356
|
+
options: ListVoicesOptions,
|
|
357
|
+
): { voices: VendorVoiceEntry[]; truncated: boolean } {
|
|
358
|
+
const keyword = options.keyword?.trim().toLowerCase() ?? ''
|
|
359
|
+
const language = options.language?.trim().toLowerCase() ?? ''
|
|
360
|
+
const source = options.source?.trim().toLowerCase() ?? ''
|
|
361
|
+
const count = cap(options)
|
|
362
|
+
const matched = entries.filter(entry => {
|
|
363
|
+
if (source !== '' && entry.source !== source) return false
|
|
364
|
+
if (language !== '') {
|
|
365
|
+
const haystack = [entry.language ?? '', entry.locale ?? ''].join(' ').toLowerCase()
|
|
366
|
+
if (!haystack.includes(language)) return false
|
|
367
|
+
}
|
|
368
|
+
if (keyword !== '') {
|
|
369
|
+
const haystack = [entry.name, entry.description ?? '', entry.accent ?? '',
|
|
370
|
+
entry.use_case ?? '', entry.gender ?? '', entry.age ?? ''].join(' ').toLowerCase()
|
|
371
|
+
for (const token of keyword.split(/\s+/)) {
|
|
372
|
+
if (token !== '' && !haystack.includes(token)) return false
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return true
|
|
376
|
+
})
|
|
377
|
+
return { voices: matched.slice(0, count), truncated: matched.length > count }
|
|
378
|
+
}
|