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