dsh-audiogen 0.4.18 → 0.4.20

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/lib/index.js CHANGED
@@ -1074,6 +1074,40 @@ const SHARED_VOICE_SORT_OPTIONS = [
1074
1074
  "oldest",
1075
1075
  "newest"
1076
1076
  ];
1077
+ /**
1078
+ * ISO 639-1 -> matching substrings across the two vendor vocabularies:
1079
+ * ElevenLabs uses ISO codes ("en"), MiniMax system ids use language labels
1080
+ * ("Chinese (Mandarin)_..." / "Japanese_..."). The aliases make one dropdown
1081
+ * value match both. "zh" also covers Cantonese (Chinese (Cantonese)).
1082
+ */
1083
+ const LANGUAGE_ALIASES = {
1084
+ zh: [
1085
+ "zh",
1086
+ "chinese",
1087
+ "mandarin",
1088
+ "cantonese",
1089
+ "yue"
1090
+ ],
1091
+ en: ["en", "english"],
1092
+ ja: ["ja", "japanese"],
1093
+ ko: ["ko", "korean"],
1094
+ es: ["es", "spanish"],
1095
+ fr: ["fr", "french"],
1096
+ de: ["de", "german"],
1097
+ ru: ["ru", "russian"],
1098
+ it: ["it", "italian"],
1099
+ pt: ["pt", "portuguese"],
1100
+ ar: ["ar", "arabic"],
1101
+ hi: ["hi", "hindi"]
1102
+ };
1103
+ function languageMatches(language, locale, needle) {
1104
+ const haystack = [language ?? "", locale ?? ""].join(" ").toLowerCase();
1105
+ const value = needle.trim().toLowerCase();
1106
+ if (value === "") return true;
1107
+ if (haystack.includes(value)) return true;
1108
+ for (const alias of LANGUAGE_ALIASES[value] ?? []) if (haystack.includes(alias)) return true;
1109
+ return false;
1110
+ }
1077
1111
  const MINIMAX_LANGUAGE_PREFIXES = [
1078
1112
  "Chinese (Mandarin)",
1079
1113
  "Chinese (Cantonese)",
@@ -1238,7 +1272,10 @@ async function listElevenLabs(channel, options) {
1238
1272
  } catch (error) {
1239
1273
  failures.push(`共享音色库:${error instanceof Error ? error.message : String(error)}`);
1240
1274
  }
1241
- if (entries.length === 0 && failures.length > 0) throw new Error(`ElevenLabs 音色列表拉取失败:${failures.join(";").slice(0, 300)}`);
1275
+ if (entries.length === 0 && failures.length > 0) {
1276
+ const gatewayHint = gatewayVoiceLibraryHint(failures);
1277
+ throw new Error(`ElevenLabs 音色列表拉取失败:${failures.join(";").slice(0, 300)}${gatewayHint}`);
1278
+ }
1242
1279
  const seen = /* @__PURE__ */ new Set();
1243
1280
  const deduped = [];
1244
1281
  for (const entry of entries) {
@@ -1246,13 +1283,23 @@ async function listElevenLabs(channel, options) {
1246
1283
  seen.add(entry.voice_id);
1247
1284
  deduped.push(entry);
1248
1285
  }
1286
+ const note = failures.length === 0 ? void 0 : `部分端点失败(已忽略):${failures.join(";").slice(0, 300)}${gatewayVoiceLibraryHint(failures)}`;
1249
1287
  return {
1250
1288
  vendor: "elevenlabs",
1251
1289
  voices: deduped,
1252
1290
  truncated: false,
1253
- ...failures.length === 0 ? {} : { note: `部分端点失败(已忽略):${failures.join(";").slice(0, 300)}` }
1291
+ ...note === void 0 ? {} : { note }
1254
1292
  };
1255
1293
  }
1294
+ /**
1295
+ * Gateway (new-api 类) 通常不映射 ElevenLabs 音色库端点,其响应是
1296
+ * 404/Invalid URL。把这种失败翻译成可操作的建议,而不是一份原始 JSON。
1297
+ */
1298
+ function gatewayVoiceLibraryHint(failures) {
1299
+ const raw = failures.join(" ").toLowerCase();
1300
+ if (!raw.includes("404") && !raw.includes("invalid url") && !raw.includes("not found")) return "";
1301
+ return "。该渠道网关未提供 ElevenLabs 音色库端点(/v1/voices、/v1/shared-voices):生成可用,但浏览/删除音色需要配置官方 API 地址 https://api.elevenlabs.io 的渠道";
1302
+ }
1256
1303
  async function listVendorVoices(channel, options = {}) {
1257
1304
  if (channel.apiUrl.trim() === "") throw new Error("渠道未配置 API 地址");
1258
1305
  if (channel.apiKey.trim() === "") throw new Error("渠道未配置 API 密钥");
@@ -1324,7 +1371,7 @@ async function deleteVendorVoice(channel, voiceId) {
1324
1371
  }
1325
1372
  function cap(options) {
1326
1373
  const limit = typeof options.limit === "number" && Number.isFinite(options.limit) ? Math.floor(options.limit) : 100;
1327
- return Math.max(1, Math.min(200, limit));
1374
+ return Math.max(1, Math.min(500, limit));
1328
1375
  }
1329
1376
  /** Which official shared-voice filters are set (for the MiniMax "not supported" note). */
1330
1377
  function serverFilterNote(filters) {
@@ -1341,9 +1388,7 @@ function applyFilter(entries, options) {
1341
1388
  const count = cap(options);
1342
1389
  const matched = entries.filter((entry) => {
1343
1390
  if (source !== "" && entry.source !== source) return false;
1344
- if (language !== "") {
1345
- if (![entry.language ?? "", entry.locale ?? ""].join(" ").toLowerCase().includes(language)) return false;
1346
- }
1391
+ if (language !== "" && !languageMatches(entry.language, entry.locale, language)) return false;
1347
1392
  if (field(filters.accent) !== "" && field(entry.accent) !== field(filters.accent)) return false;
1348
1393
  if (field(filters.gender) !== "" && field(entry.gender) !== field(filters.gender)) return false;
1349
1394
  if (field(filters.age) !== "" && field(entry.age) !== field(filters.age)) return false;
@@ -1367,6 +1412,7 @@ function applyFilter(entries, options) {
1367
1412
  }
1368
1413
  return true;
1369
1414
  });
1415
+ matched.sort((a, b) => a.deletable === b.deletable ? 0 : a.deletable ? -1 : 1);
1370
1416
  return {
1371
1417
  voices: matched.slice(0, count),
1372
1418
  truncated: matched.length > count
@@ -2421,6 +2467,7 @@ function makeRoutes(deps) {
2421
2467
  ...str("language") === void 0 ? {} : { language: str("language") },
2422
2468
  ...str("keyword") === void 0 ? {} : { keyword: str("keyword") },
2423
2469
  ...str("source") === void 0 ? {} : { source: str("source") },
2470
+ ...typeof body?.limit === "number" && Number.isFinite(body.limit) ? { limit: Math.floor(body.limit) } : {},
2424
2471
  ...Object.keys(filters).length === 0 ? {} : { serverFilters: filters }
2425
2472
  });
2426
2473
  writeJson(res, 200, {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-audiogen",
3
3
  "description": "AI audio generation plugin for the dsh web GUI: multi-vendor TTS/music/sound-effect channels (OpenAI-compatible, ElevenLabs, MiniMax, Stability AI and custom), per-channel model/voice catalogs, Agent tool and a sidebar AI 音频 panel.",
4
- "version": "0.4.18",
4
+ "version": "0.4.20",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -31,6 +31,23 @@ const SOURCE_LABELS: Record<VoiceEntry['source'], string> = {
31
31
  shared: '社区共享',
32
32
  }
33
33
 
34
+ /** 语言下拉:ISO 639-1 值;后端按两套厂商词汇匹配(ElevenLabs ISO / MiniMax 前缀)。 */
35
+ const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
36
+ { value: '', label: '全部' },
37
+ { value: 'zh', label: '中文(普通话 / 粤语)' },
38
+ { value: 'en', label: '英语 English' },
39
+ { value: 'ja', label: '日语 Japanese' },
40
+ { value: 'ko', label: '韩语 Korean' },
41
+ { value: 'es', label: '西班牙语 Spanish' },
42
+ { value: 'fr', label: '法语 French' },
43
+ { value: 'de', label: '德语 German' },
44
+ { value: 'ru', label: '俄语 Russian' },
45
+ { value: 'it', label: '意大利语 Italian' },
46
+ { value: 'pt', label: '葡萄牙语 Portuguese' },
47
+ { value: 'ar', label: '阿拉伯语 Arabic' },
48
+ { value: 'hi', label: '印地语 Hindi' },
49
+ ]
50
+
34
51
  function useConfig(scope: AudiogenScope) {
35
52
  const [value, setValue] = useState(scope.getSnapshot().value)
36
53
  useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
@@ -76,6 +93,7 @@ export function VoicesView(props: {
76
93
  const [freeUsersAllowed, setFreeUsersAllowed] = useState(false)
77
94
 
78
95
  const [voices, setVoices] = useState<VoiceEntry[]>([])
96
+ const [truncated, setTruncated] = useState(false)
79
97
  const [loading, setLoading] = useState(false)
80
98
  const [error, setError] = useState<string | null>(null)
81
99
  const [note, setNote] = useState<string | null>(null)
@@ -103,9 +121,11 @@ export function VoicesView(props: {
103
121
  ...(sort === '' ? {} : { sort }),
104
122
  ...(featured ? { featured: true } : {}),
105
123
  ...(freeUsersAllowed ? { free_users_allowed: true } : {}),
124
+ limit: 500,
106
125
  })
107
126
  if (response.ok) {
108
127
  setVoices(response.voices ?? [])
128
+ setTruncated(response.truncated === true)
109
129
  setNote(response.note ?? null)
110
130
  } else {
111
131
  setVoices([])
@@ -168,7 +188,11 @@ export function VoicesView(props: {
168
188
  </label>
169
189
  <label className={css.field}>
170
190
  <span className={css.fieldLabel}>语言</span>
171
- <input className={css.input} value={language} onChange={event => setLanguage(event.target.value)} placeholder="en / zh / ja…" />
191
+ <select className={css.select} value={language} onChange={event => setLanguage(event.target.value)}>
192
+ {LANGUAGE_OPTIONS.map(option => (
193
+ <option key={option.value} value={option.value}>{option.label}</option>
194
+ ))}
195
+ </select>
172
196
  </label>
173
197
  <label className={css.field}>
174
198
  <span className={css.fieldLabel}>关键词</span>
@@ -218,6 +242,7 @@ export function VoicesView(props: {
218
242
  {loading ? <span className={css.stateNote}>正在拉取音色列表…</span> : null}
219
243
  {!loading && error !== null ? <span className={css.stateNote} data-error>⚠ {error}</span> : null}
220
244
  {!loading && note !== null ? <span className={css.stateNote}>{note}</span> : null}
245
+ {!loading && truncated ? <span className={css.stateNote}>已截断:仅显示前 {voices.length} 个,可用筛选条件缩小范围</span> : null}
221
246
  {!loading && error === null ? <span className={css.count}>{voices.length} 个音色</span> : null}
222
247
  </div>
223
248
 
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const AUDIOGEN_SETTINGS_NAMESPACE = 'dsh-audiogen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '0.4.18'
11
+ export const PLUGIN_VERSION = '0.4.20'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
14
14
  export const SETTINGS_API = {
@@ -70,6 +70,8 @@ export interface VoicesListRequest {
70
70
  language?: string
71
71
  keyword?: string
72
72
  source?: string
73
+ /** Max returned entries (default 100, hard cap 500). */
74
+ limit?: number
73
75
  /** Official /v1/shared-voices server-side filters (ElevenLabs only). */
74
76
  search?: string
75
77
  use_case?: string
package/src/routes.ts CHANGED
@@ -426,6 +426,7 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
426
426
  ...(str('language') === undefined ? {} : { language: str('language')! }),
427
427
  ...(str('keyword') === undefined ? {} : { keyword: str('keyword')! }),
428
428
  ...(str('source') === undefined ? {} : { source: str('source')! }),
429
+ ...(typeof body?.limit === 'number' && Number.isFinite(body.limit) ? { limit: Math.floor(body.limit) } : {}),
429
430
  ...(Object.keys(filters).length === 0 ? {} : { serverFilters: filters }),
430
431
  })
431
432
  writeJson(res, 200, {
@@ -70,6 +70,38 @@ export interface SharedVoiceFilters {
70
70
 
71
71
  const SHARED_VOICE_SORT_OPTIONS = ['most_used', 'random', 'oldest', 'newest'] as const
72
72
 
73
+ /**
74
+ * ISO 639-1 -> matching substrings across the two vendor vocabularies:
75
+ * ElevenLabs uses ISO codes ("en"), MiniMax system ids use language labels
76
+ * ("Chinese (Mandarin)_..." / "Japanese_..."). The aliases make one dropdown
77
+ * value match both. "zh" also covers Cantonese (Chinese (Cantonese)).
78
+ */
79
+ const LANGUAGE_ALIASES: Record<string, string[]> = {
80
+ zh: ['zh', 'chinese', 'mandarin', 'cantonese', 'yue'],
81
+ en: ['en', 'english'],
82
+ ja: ['ja', 'japanese'],
83
+ ko: ['ko', 'korean'],
84
+ es: ['es', 'spanish'],
85
+ fr: ['fr', 'french'],
86
+ de: ['de', 'german'],
87
+ ru: ['ru', 'russian'],
88
+ it: ['it', 'italian'],
89
+ pt: ['pt', 'portuguese'],
90
+ ar: ['ar', 'arabic'],
91
+ hi: ['hi', 'hindi'],
92
+ }
93
+
94
+ function languageMatches(language: string | undefined, locale: string | undefined, needle: string): boolean {
95
+ const haystack = [language ?? '', locale ?? ''].join(' ').toLowerCase()
96
+ const value = needle.trim().toLowerCase()
97
+ if (value === '') return true
98
+ if (haystack.includes(value)) return true
99
+ for (const alias of LANGUAGE_ALIASES[value] ?? []) {
100
+ if (haystack.includes(alias)) return true
101
+ }
102
+ return false
103
+ }
104
+
73
105
  export interface ListVoicesResult {
74
106
  vendor: string
75
107
  voices: VendorVoiceEntry[]
@@ -272,7 +304,10 @@ async function listElevenLabs(channel: AudioChannel, options: ListVoicesOptions)
272
304
  }
273
305
 
274
306
  if (entries.length === 0 && failures.length > 0) {
275
- throw new Error(`ElevenLabs 音色列表拉取失败:${failures.join(';').slice(0, 300)}`)
307
+ const gatewayHint = gatewayVoiceLibraryHint(failures)
308
+ throw new Error(
309
+ `ElevenLabs 音色列表拉取失败:${failures.join(';').slice(0, 300)}${gatewayHint}`,
310
+ )
276
311
  }
277
312
  // Owned voices come first and shadow shared ones with the same id.
278
313
  const seen = new Set<string>()
@@ -282,14 +317,25 @@ async function listElevenLabs(channel: AudioChannel, options: ListVoicesOptions)
282
317
  seen.add(entry.voice_id)
283
318
  deduped.push(entry)
284
319
  }
320
+ const note = failures.length === 0 ? undefined : `部分端点失败(已忽略):${failures.join(';').slice(0, 300)}${gatewayVoiceLibraryHint(failures)}`
285
321
  return {
286
322
  vendor: 'elevenlabs',
287
323
  voices: deduped,
288
324
  truncated: false,
289
- ...(failures.length === 0 ? {} : { note: `部分端点失败(已忽略):${failures.join(';').slice(0, 300)}` }),
325
+ ...(note === undefined ? {} : { note }),
290
326
  }
291
327
  }
292
328
 
329
+ /**
330
+ * Gateway (new-api 类) 通常不映射 ElevenLabs 音色库端点,其响应是
331
+ * 404/Invalid URL。把这种失败翻译成可操作的建议,而不是一份原始 JSON。
332
+ */
333
+ function gatewayVoiceLibraryHint(failures: string[]): string {
334
+ const raw = failures.join(' ').toLowerCase()
335
+ if (!raw.includes('404') && !raw.includes('invalid url') && !raw.includes('not found')) return ''
336
+ return '。该渠道网关未提供 ElevenLabs 音色库端点(/v1/voices、/v1/shared-voices):生成可用,但浏览/删除音色需要配置官方 API 地址 https://api.elevenlabs.io 的渠道'
337
+ }
338
+
293
339
  // ------------------------------------------------------ public surface
294
340
 
295
341
  export async function listVendorVoices(
@@ -387,7 +433,7 @@ function cap(options: ListVoicesOptions): number {
387
433
  const limit = typeof options.limit === 'number' && Number.isFinite(options.limit)
388
434
  ? Math.floor(options.limit)
389
435
  : 100
390
- return Math.max(1, Math.min(200, limit))
436
+ return Math.max(1, Math.min(500, limit))
391
437
  }
392
438
 
393
439
  /** Which official shared-voice filters are set (for the MiniMax "not supported" note). */
@@ -412,10 +458,7 @@ function applyFilter(
412
458
  const count = cap(options)
413
459
  const matched = entries.filter(entry => {
414
460
  if (source !== '' && entry.source !== source) return false
415
- if (language !== '') {
416
- const haystack = [entry.language ?? '', entry.locale ?? ''].join(' ').toLowerCase()
417
- if (!haystack.includes(language)) return false
418
- }
461
+ if (language !== '' && !languageMatches(entry.language, entry.locale, language)) return false
419
462
  // Official shared-voice filters double as local fallback filters so they
420
463
  // also apply to owned voices and providers without server-side support.
421
464
  if (field(filters.accent) !== '' && field(entry.accent) !== field(filters.accent)) return false
@@ -438,5 +481,8 @@ function applyFilter(
438
481
  }
439
482
  return true
440
483
  })
484
+ // Deletable (account-owned/custom) voices first so narrow default windows
485
+ // show the manageable ones; stable sort keeps provider order inside groups.
486
+ matched.sort((a, b) => (a.deletable === b.deletable ? 0 : a.deletable ? -1 : 1))
441
487
  return { voices: matched.slice(0, count), truncated: matched.length > count }
442
488
  }