dsh-audiogen 0.4.13 → 0.4.14

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
@@ -332,7 +332,7 @@ async function openAITTS(channel, request, signal) {
332
332
  fallbackMime: "audio/mpeg"
333
333
  });
334
334
  }
335
- async function elevenLabs(channel, request, signal) {
335
+ async function elevenLabsOfficial(channel, request, signal) {
336
336
  const base = endpointBase(channel.apiUrl);
337
337
  const model = (request.upstream ?? request.model) || "eleven_multilingual_v2";
338
338
  const headers = {
@@ -377,12 +377,15 @@ async function elevenLabs(channel, request, signal) {
377
377
  }
378
378
  if (request.mode === "music") {
379
379
  const endpoint = `${base}/music`;
380
+ const musicModel = (request.upstream ?? request.model) || "music_v1";
381
+ const lyrics = request.lyrics?.trim() ?? "";
382
+ const instrumental = request.isInstrumental === true || lyrics === "";
380
383
  const body = {
381
- model_id: (request.upstream ?? request.model) || "music_v1",
384
+ model_id: musicModel,
382
385
  prompt: request.prompt,
383
386
  ...request.duration !== void 0 && Number.isFinite(request.duration) ? { music_length_ms: Math.round(Math.min(6e5, Math.max(3e3, request.duration * 1e3))) } : {},
384
- ...request.lyrics !== void 0 && request.lyrics.trim() !== "" ? { lyrics_text: request.lyrics.trim() } : {},
385
- ...request.isInstrumental !== void 0 ? { force_instrumental: request.isInstrumental } : {}
387
+ ...lyrics === "" ? {} : { lyrics_text: lyrics },
388
+ ...instrumental ? { force_instrumental: true } : {}
386
389
  };
387
390
  const response = await fetchWithTimeout(endpoint, {
388
391
  method: "POST",
@@ -450,6 +453,120 @@ async function elevenLabs(channel, request, signal) {
450
453
  fallbackMime: "audio/mpeg"
451
454
  });
452
455
  }
456
+ /**
457
+ * 官方 ElevenLabs 请求被网关拒绝的信号:官方路径未映射(404 Invalid URL)或
458
+ * 网关要求 Bearer 认证而非 xi-api-key(401/403 Invalid token / Invalid API key)。
459
+ * New API 类中转(如 ai.farmmx.com)对 ElevenLabs 官方协议通常返回这类错误。
460
+ */
461
+ function isGatewayRouteMiss(error) {
462
+ return error instanceof AudioGenError && error.code === "audio-api-error" && /\bHTTP (404|401|403)\b/.test(error.message) && /\bInvalid URL\b|\bInvalid token\b|\bInvalid API key\b/i.test(error.message);
463
+ }
464
+ /** 网关兼容形态的请求头:仅 Bearer(携带 xi-api-key 会被网关按官方协议校验而 401)。 */
465
+ function gatewayHeaders(apiKey) {
466
+ return {
467
+ authorization: `Bearer ${apiKey.trim()}`,
468
+ "content-type": "application/json",
469
+ accept: "audio/mpeg, application/json"
470
+ };
471
+ }
472
+ /** /audio/speech 兼容端点:base 已以此结尾时直接复用,否则拼接。 */
473
+ function speechGatewayEndpoint(base) {
474
+ return /\/audio\/speech(\?|$)/i.test(base) ? base : `${base}/audio/speech`;
475
+ }
476
+ /**
477
+ * ElevenLabs 渠道的网关兼容形态(OpenAI 风格):路径用 /audio/speech(音效/TTS)
478
+ * 或 /music(音乐),认证用 Bearer、模型用 `model` 字段。
479
+ *
480
+ * 适配未映射 ElevenLabs 官方端点(404 Invalid URL)或要求 Bearer 认证
481
+ * (401 Invalid token)的 New API 类中转,如 ai.farmmx.com。
482
+ */
483
+ async function elevenLabsGatewayCompat(channel, request, signal) {
484
+ const base = endpointBase(channel.apiUrl);
485
+ const headers = gatewayHeaders(channel.apiKey);
486
+ if (request.mode === "music") {
487
+ const endpoint = /\/music(\?|$)/i.test(base) ? base : `${base}/music`;
488
+ const musicModel = (request.upstream ?? request.model) || "music_v1";
489
+ const lyrics = request.lyrics?.trim() ?? "";
490
+ const instrumental = request.isInstrumental === true || lyrics === "";
491
+ const body = {
492
+ model: musicModel,
493
+ prompt: request.prompt,
494
+ ...request.duration !== void 0 && Number.isFinite(request.duration) ? { music_length_ms: Math.round(Math.min(6e5, Math.max(3e3, request.duration * 1e3))) } : {},
495
+ ...lyrics === "" ? {} : { lyrics_text: lyrics },
496
+ ...instrumental ? { force_instrumental: true } : {}
497
+ };
498
+ const response = await fetchWithTimeout(endpoint, {
499
+ method: "POST",
500
+ redirect: "follow",
501
+ headers,
502
+ body: JSON.stringify(body),
503
+ signal
504
+ }, UPSTREAM_TIMEOUT_MS);
505
+ if (!response.ok) {
506
+ const detail = await response.text().catch(() => "");
507
+ throw new AudioGenError(`ElevenLabs music gateway-compatible API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
508
+ }
509
+ return normalizeAudioResponse(response, {
510
+ apiKey: channel.apiKey,
511
+ fallbackMime: "audio/mpeg"
512
+ });
513
+ }
514
+ if (request.mode === "voice_design") throw new AudioGenError("当前网关不支持 ElevenLabs 音色设计端点(POST /v1/text-to-voice/design);该模式请改用 MiniMax 渠道或 ElevenLabs 官方 API。", "voice-design-unsupported");
515
+ const endpoint = speechGatewayEndpoint(base);
516
+ const isSfx = request.mode === "sfx";
517
+ const model = (request.upstream ?? request.model) || (isSfx ? "eleven_text_to_sound_v2" : "eleven_multilingual_v2");
518
+ if (!isSfx) {
519
+ if ((request.voice?.trim() ?? "") === "") {
520
+ const suggestions = (channel.models ?? []).filter((entry) => {
521
+ const candidate = entry;
522
+ return candidate.category === "tts" && candidate.id.trim() !== "" && candidate.id !== candidate.alias;
523
+ }).map((entry) => `${entry.alias}(${entry.id})`);
524
+ throw new AudioGenError(`ElevenLabs 网关渠道的 TTS 必须携带音色 voice_id(网关强制校验,缺失会返回 400 voice or voice_id is required):请在「音色」字段填入官方音色 ID${suggestions.length === 0 ? "" : `,可选用以下音色:${suggestions.slice(0, 4).join("、")}`}。`, "voice-required");
525
+ }
526
+ }
527
+ const body = isSfx ? {
528
+ model,
529
+ input: request.prompt,
530
+ ...request.duration !== void 0 && Number.isFinite(request.duration) ? { duration_seconds: Math.min(30, Math.max(.5, request.duration)) } : {},
531
+ ...request.loop !== void 0 ? { loop: request.loop } : {},
532
+ ...request.promptInfluence !== void 0 && Number.isFinite(request.promptInfluence) ? { prompt_influence: Math.min(1, Math.max(0, request.promptInfluence)) } : {}
533
+ } : {
534
+ model,
535
+ input: request.prompt,
536
+ voice: request.voice.trim(),
537
+ response_format: request.format ?? "mp3",
538
+ ...request.speed !== void 0 ? { speed: request.speed } : {}
539
+ };
540
+ const response = await fetchWithTimeout(endpoint, {
541
+ method: "POST",
542
+ redirect: "follow",
543
+ headers,
544
+ body: JSON.stringify(body),
545
+ signal
546
+ }, UPSTREAM_TIMEOUT_MS);
547
+ if (!response.ok) {
548
+ const detail = await response.text().catch(() => "");
549
+ throw new AudioGenError(`ElevenLabs ${isSfx ? "sound effects" : "TTS"} gateway-compatible API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail.slice(0, 300)}`}`, "audio-api-error");
550
+ }
551
+ return normalizeAudioResponse(response, {
552
+ apiKey: channel.apiKey,
553
+ fallbackMime: "audio/mpeg"
554
+ });
555
+ }
556
+ /**
557
+ * ElevenLabs 渠道入口:官方端点优先;官方协议被网关(New API 类中转)拒绝时,
558
+ * 自动改用 OpenAI 兼容形态重试,使同一渠道同时兼容 ElevenLabs 官方 API 与
559
+ * ai.farmmx.com 类中转。官方地址(api.elevenlabs.io)直连不触发回退。
560
+ */
561
+ async function elevenLabs(channel, request, signal) {
562
+ if (/elevenlabs\.io/i.test(channel.apiUrl)) return elevenLabsOfficial(channel, request, signal);
563
+ try {
564
+ return await elevenLabsOfficial(channel, request, signal);
565
+ } catch (error) {
566
+ if (!isGatewayRouteMiss(error)) throw error;
567
+ }
568
+ return elevenLabsGatewayCompat(channel, request, signal);
569
+ }
453
570
  function minimaxApiBase(base) {
454
571
  const trimmed = endpointBase(base);
455
572
  return /\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/v1`;
@@ -614,13 +731,13 @@ async function minimax(channel, request, signal) {
614
731
  256e3
615
732
  ]);
616
733
  const lyrics = request.lyrics?.trim() ?? "";
617
- if (lyrics === "" && request.isInstrumental !== true) throw new AudioGenError("MiniMax 音乐生成需要歌词(lyrics 参数),或在「纯音乐」模式(is_instrumental=true)下生成;也可让面板/Agent 先为提示词创作一段歌词。", "lyrics-required");
734
+ const instrumental = request.isInstrumental === true || lyrics === "";
618
735
  const endpoint = `${base}/music_generation`;
619
736
  const body = {
620
737
  model,
621
738
  prompt: request.prompt,
622
739
  ...lyrics === "" ? {} : { lyrics },
623
- ...request.isInstrumental !== void 0 ? { is_instrumental: request.isInstrumental } : {},
740
+ ...instrumental ? { is_instrumental: true } : {},
624
741
  ...request.duration !== void 0 ? { duration: request.duration } : {},
625
742
  audio_setting: {
626
743
  format: MUSIC_FORMATS.has(request.format ?? "mp3") ? request.format ?? "mp3" : "mp3",
@@ -2100,11 +2217,13 @@ function makeRoutes(deps) {
2100
2217
  type: entry.type
2101
2218
  }];
2102
2219
  } catch {}
2220
+ const note = request.mode === "music" && request.isInstrumental !== true && (request.lyrics === void 0 || request.lyrics.trim() === "") ? "未提供歌词,已按纯音乐生成" : void 0;
2103
2221
  writeJson(res, 200, {
2104
2222
  ok: true,
2105
2223
  outputs: generated,
2106
2224
  history,
2107
- ...resources === void 0 ? {} : { resources }
2225
+ ...resources === void 0 ? {} : { resources },
2226
+ ...note === void 0 ? {} : { note }
2108
2227
  });
2109
2228
  } catch (error) {
2110
2229
  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.13",
4
+ "version": "0.4.14",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -33,3 +33,9 @@ whenToUse: 用户请求生成音效、提示音、环境音、UI 音,或触发
33
33
  | prompt_influence | prompt_influence | 0-1,默认 0.3;越高越贴提示词,越低越多样 |
34
34
 
35
35
  > 响应为 audio/mpeg 二进制。请求同时携带 `xi-api-key` 与 `Authorization: Bearer`,兼容 New API 类网关。
36
+ >
37
+ > 网关自动回退:当渠道 API 地址不是 ElevenLabs 官方域名且官方协议被网关拒绝时
38
+ > (404 Invalid URL / 401 Invalid token,例如 ai.farmmx.com 未映射 `/v1/sound-generation`),
39
+ > 引擎自动改用 OpenAI 兼容形态重试:`POST {base}/audio/speech` + `Authorization: Bearer` +
40
+ > `model=eleven_text_to_sound_v2` + `text/duration_seconds/prompt_influence/loop` 字段。
41
+ > 官方地址(api.elevenlabs.io)直连时不触发回退。音色设计在网关无对应兼容端点,会明确报错不便死等。
@@ -245,7 +245,7 @@ async function openAITTS(channel: AudioChannel, request: GenerateAudioRequest, s
245
245
  return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
246
246
  }
247
247
 
248
- async function elevenLabs(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
248
+ async function elevenLabsOfficial(channel: AudioChannel, request: GenerateAudioRequest, signal?: AbortSignal): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
249
249
  const base = endpointBase(channel.apiUrl)
250
250
  const model = (request.upstream ?? request.model) || 'eleven_multilingual_v2'
251
251
  // 官方使用 xi-api-key;额外携带 Authorization Bearer 以兼容 New API 类网关。
@@ -299,17 +299,20 @@ async function elevenLabs(channel: AudioChannel, request: GenerateAudioRequest,
299
299
 
300
300
  // ------------- ElevenLabs Music(POST /v1/music) -------------
301
301
  // 模型:music_v1 / music_v2;prompt 与 composition_plan 二选一(引擎用 prompt)。
302
+ // 未提供歌词时按纯音乐处理(force_instrumental=true),不再要求必须有歌词。
302
303
  if (request.mode === 'music') {
303
304
  const endpoint = `${base}/music`
304
305
  const musicModel = (request.upstream ?? request.model) || 'music_v1'
306
+ const lyrics = request.lyrics?.trim() ?? ''
307
+ const instrumental = request.isInstrumental === true || lyrics === ''
305
308
  const body: Record<string, unknown> = {
306
309
  model_id: musicModel,
307
310
  prompt: request.prompt,
308
311
  ...(request.duration !== undefined && Number.isFinite(request.duration)
309
312
  ? { music_length_ms: Math.round(Math.min(600_000, Math.max(3_000, request.duration * 1000))) }
310
313
  : {}),
311
- ...(request.lyrics !== undefined && request.lyrics.trim() !== '' ? { lyrics_text: request.lyrics.trim() } : {}),
312
- ...(request.isInstrumental !== undefined ? { force_instrumental: request.isInstrumental } : {}),
314
+ ...(lyrics === '' ? {} : { lyrics_text: lyrics }),
315
+ ...(instrumental ? { force_instrumental: true } : {}),
313
316
  }
314
317
  const response = await fetchWithTimeout(endpoint, {
315
318
  method: 'POST',
@@ -379,6 +382,166 @@ async function elevenLabs(channel: AudioChannel, request: GenerateAudioRequest,
379
382
  return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
380
383
  }
381
384
 
385
+ /**
386
+ * 官方 ElevenLabs 请求被网关拒绝的信号:官方路径未映射(404 Invalid URL)或
387
+ * 网关要求 Bearer 认证而非 xi-api-key(401/403 Invalid token / Invalid API key)。
388
+ * New API 类中转(如 ai.farmmx.com)对 ElevenLabs 官方协议通常返回这类错误。
389
+ */
390
+ function isGatewayRouteMiss(error: unknown): boolean {
391
+ return error instanceof AudioGenError
392
+ && error.code === 'audio-api-error'
393
+ && /\bHTTP (404|401|403)\b/.test(error.message)
394
+ && /\bInvalid URL\b|\bInvalid token\b|\bInvalid API key\b/i.test(error.message)
395
+ }
396
+
397
+ /** 网关兼容形态的请求头:仅 Bearer(携带 xi-api-key 会被网关按官方协议校验而 401)。 */
398
+ function gatewayHeaders(apiKey: string): Record<string, string> {
399
+ return {
400
+ authorization: `Bearer ${apiKey.trim()}`,
401
+ 'content-type': 'application/json',
402
+ accept: 'audio/mpeg, application/json',
403
+ }
404
+ }
405
+
406
+ /** /audio/speech 兼容端点:base 已以此结尾时直接复用,否则拼接。 */
407
+ function speechGatewayEndpoint(base: string): string {
408
+ return /\/audio\/speech(\?|$)/i.test(base) ? base : `${base}/audio/speech`
409
+ }
410
+
411
+ /**
412
+ * ElevenLabs 渠道的网关兼容形态(OpenAI 风格):路径用 /audio/speech(音效/TTS)
413
+ * 或 /music(音乐),认证用 Bearer、模型用 `model` 字段。
414
+ *
415
+ * 适配未映射 ElevenLabs 官方端点(404 Invalid URL)或要求 Bearer 认证
416
+ * (401 Invalid token)的 New API 类中转,如 ai.farmmx.com。
417
+ */
418
+ async function elevenLabsGatewayCompat(
419
+ channel: AudioChannel,
420
+ request: GenerateAudioRequest,
421
+ signal?: AbortSignal,
422
+ ): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
423
+ const base = endpointBase(channel.apiUrl)
424
+ const headers = gatewayHeaders(channel.apiKey)
425
+
426
+ // music → POST /music(Bearer + model;官方形态在此类网关上是 401 Invalid token)。
427
+ if (request.mode === 'music') {
428
+ const endpoint = /\/music(\?|$)/i.test(base) ? base : `${base}/music`
429
+ const musicModel = (request.upstream ?? request.model) || 'music_v1'
430
+ const lyrics = request.lyrics?.trim() ?? ''
431
+ const instrumental = request.isInstrumental === true || lyrics === ''
432
+ const body: Record<string, unknown> = {
433
+ model: musicModel,
434
+ prompt: request.prompt,
435
+ ...(request.duration !== undefined && Number.isFinite(request.duration)
436
+ ? { music_length_ms: Math.round(Math.min(600_000, Math.max(3_000, request.duration * 1000))) }
437
+ : {}),
438
+ ...(lyrics === '' ? {} : { lyrics_text: lyrics }),
439
+ ...(instrumental ? { force_instrumental: true } : {}),
440
+ }
441
+ const response = await fetchWithTimeout(endpoint, {
442
+ method: 'POST',
443
+ redirect: 'follow',
444
+ headers,
445
+ body: JSON.stringify(body),
446
+ signal,
447
+ }, UPSTREAM_TIMEOUT_MS)
448
+ if (!response.ok) {
449
+ const detail = await response.text().catch(() => '')
450
+ throw new AudioGenError(`ElevenLabs music gateway-compatible API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`, 'audio-api-error')
451
+ }
452
+ return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
453
+ }
454
+
455
+ // 音色设计在网关兼容层没有对应端点,直接给出可操作的错误说明。
456
+ if (request.mode === 'voice_design') {
457
+ throw new AudioGenError(
458
+ '当前网关不支持 ElevenLabs 音色设计端点(POST /v1/text-to-voice/design);该模式请改用 MiniMax 渠道或 ElevenLabs 官方 API。',
459
+ 'voice-design-unsupported',
460
+ )
461
+ }
462
+
463
+ // sfx / tts → POST /audio/speech(OpenAI 兼容形态;网关把 model=eleven_text_to_sound_v2 映射到音效生成)。
464
+ // 音效必须传 OpenAI 标准的 `input`(实测 ai.farmmx.com 类 new-api 网关:input+duration_seconds
465
+ // 会走 ElevenLabs 音效生成;若传 ElevenLabs 官方字段 `text`,网关会把它当 TTS 文本朗读出来)。
466
+ const endpoint = speechGatewayEndpoint(base)
467
+ const isSfx = request.mode === 'sfx'
468
+ const model = (request.upstream ?? request.model)
469
+ || (isSfx ? 'eleven_text_to_sound_v2' : 'eleven_multilingual_v2')
470
+ // 网关(new-api 类中转)对 ElevenLabs TTS 强制校验 voice/voice_id,缺失直接 400;
471
+ // 在发送前拦截,给出可操作的提示,而不是把网关 400 原样抛给用户。
472
+ if (!isSfx) {
473
+ const voice = request.voice?.trim() ?? ''
474
+ if (voice === '') {
475
+ const voiceEntries = (channel.models ?? []).filter(entry => {
476
+ const candidate = entry as { alias: string; id: string; category?: string }
477
+ return candidate.category === 'tts' && candidate.id.trim() !== '' && candidate.id !== candidate.alias
478
+ })
479
+ const suggestions = voiceEntries.map(entry => `${entry.alias}(${entry.id})`)
480
+ throw new AudioGenError(
481
+ `ElevenLabs 网关渠道的 TTS 必须携带音色 voice_id(网关强制校验,缺失会返回 400 voice or voice_id is required):请在「音色」字段填入官方音色 ID${
482
+ suggestions.length === 0 ? '' : `,可选用以下音色:${suggestions.slice(0, 4).join('、')}`
483
+ }。`,
484
+ 'voice-required',
485
+ )
486
+ }
487
+ }
488
+ const body: Record<string, unknown> = isSfx
489
+ ? {
490
+ model,
491
+ input: request.prompt,
492
+ ...(request.duration !== undefined && Number.isFinite(request.duration)
493
+ ? { duration_seconds: Math.min(30, Math.max(0.5, request.duration)) }
494
+ : {}),
495
+ ...(request.loop !== undefined ? { loop: request.loop } : {}),
496
+ ...(request.promptInfluence !== undefined && Number.isFinite(request.promptInfluence)
497
+ ? { prompt_influence: Math.min(1, Math.max(0, request.promptInfluence)) }
498
+ : {}),
499
+ }
500
+ : {
501
+ model,
502
+ input: request.prompt,
503
+ voice: request.voice!.trim(),
504
+ response_format: request.format ?? 'mp3',
505
+ ...(request.speed !== undefined ? { speed: request.speed } : {}),
506
+ }
507
+ const response = await fetchWithTimeout(endpoint, {
508
+ method: 'POST',
509
+ redirect: 'follow',
510
+ headers,
511
+ body: JSON.stringify(body),
512
+ signal,
513
+ }, UPSTREAM_TIMEOUT_MS)
514
+ if (!response.ok) {
515
+ const detail = await response.text().catch(() => '')
516
+ throw new AudioGenError(
517
+ `ElevenLabs ${isSfx ? 'sound effects' : 'TTS'} gateway-compatible API error (HTTP ${response.status})${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`,
518
+ 'audio-api-error',
519
+ )
520
+ }
521
+ return normalizeAudioResponse(response, { apiKey: channel.apiKey, fallbackMime: 'audio/mpeg' })
522
+ }
523
+
524
+ /**
525
+ * ElevenLabs 渠道入口:官方端点优先;官方协议被网关(New API 类中转)拒绝时,
526
+ * 自动改用 OpenAI 兼容形态重试,使同一渠道同时兼容 ElevenLabs 官方 API 与
527
+ * ai.farmmx.com 类中转。官方地址(api.elevenlabs.io)直连不触发回退。
528
+ */
529
+ async function elevenLabs(
530
+ channel: AudioChannel,
531
+ request: GenerateAudioRequest,
532
+ signal?: AbortSignal,
533
+ ): Promise<Array<{ data: Uint8Array; mime: string; voiceId?: string }>> {
534
+ if (/elevenlabs\.io/i.test(channel.apiUrl)) {
535
+ return elevenLabsOfficial(channel, request, signal)
536
+ }
537
+ try {
538
+ return await elevenLabsOfficial(channel, request, signal)
539
+ } catch (error) {
540
+ if (!isGatewayRouteMiss(error)) throw error
541
+ }
542
+ return elevenLabsGatewayCompat(channel, request, signal)
543
+ }
544
+
382
545
  function minimaxApiBase(base: string): string {
383
546
  const trimmed = endpointBase(base)
384
547
  return /\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/v1`
@@ -551,22 +714,19 @@ async function minimax(channel: AudioChannel, request: GenerateAudioRequest, sig
551
714
  // audio_setting{format, sample_rate, bitrate}。音频输出配置为固定枚举:
552
715
  // format mp3|wav|pcm;sample_rate 16000|24000|32000|44100;
553
716
  // bitrate 32000|64000|128000|256000,超出枚举的值回退默认。
717
+ // 歌词为空时一律按纯音乐生成(is_instrumental=true):面板/Agent 无论是否
718
+ // 显式勾选「纯音乐」都能出结果,不再因缺歌词报错。
554
719
  const MUSIC_FORMATS = new Set(['mp3', 'wav', 'pcm'])
555
720
  const MUSIC_SAMPLE_RATES = new Set([16000, 24000, 32000, 44100])
556
721
  const MUSIC_BITRATES = new Set([32000, 64000, 128000, 256000])
557
722
  const lyrics = request.lyrics?.trim() ?? ''
558
- if (lyrics === '' && request.isInstrumental !== true) {
559
- throw new AudioGenError(
560
- 'MiniMax 音乐生成需要歌词(lyrics 参数),或在「纯音乐」模式(is_instrumental=true)下生成;也可让面板/Agent 先为提示词创作一段歌词。',
561
- 'lyrics-required',
562
- )
563
- }
723
+ const instrumental = request.isInstrumental === true || lyrics === ''
564
724
  const endpoint = `${base}/music_generation`
565
725
  const body: Record<string, unknown> = {
566
726
  model,
567
727
  prompt: request.prompt,
568
728
  ...(lyrics === '' ? {} : { lyrics }),
569
- ...(request.isInstrumental !== undefined ? { is_instrumental: request.isInstrumental } : {}),
729
+ ...(instrumental ? { is_instrumental: true } : {}),
570
730
  ...(request.duration !== undefined ? { duration: request.duration } : {}),
571
731
  audio_setting: {
572
732
  format: MUSIC_FORMATS.has(request.format ?? 'mp3') ? (request.format ?? 'mp3') : 'mp3',
package/src/client/api.ts CHANGED
@@ -16,6 +16,8 @@ export interface GenerateResponse {
16
16
  historyError?: string
17
17
  /** Resource-library entries created by the generation (auto-save). */
18
18
  resources?: Array<{ id: string; name: string; type: string }>
19
+ /** 引擎兜底提示(如:未提供歌词时按纯音乐生成)。 */
20
+ note?: string
19
21
  code?: string
20
22
  message?: string
21
23
  }
@@ -1171,6 +1171,13 @@
1171
1171
  grid-column: 1 / -1;
1172
1172
  }
1173
1173
 
1174
+ /* 勾选纯音乐后禁用的歌词框 */
1175
+ .textarea:disabled {
1176
+ opacity: 0.55;
1177
+ cursor: not-allowed;
1178
+ background: var(--dsw-alias-bg-layer-2, #f3f4f6);
1179
+ }
1180
+
1174
1181
  /* 模式胶囊 */
1175
1182
  .modeIcon {
1176
1183
  font-size: 13px;
@@ -91,7 +91,7 @@ export function presetSupports(preset: string, key: FieldKey, mode: AudioMode):
91
91
  }
92
92
 
93
93
  const SPECS: Record<FieldKey, Omit<FieldSpec, 'key' | 'presets'>> = {
94
- duration: { label: '时长(秒)', type: 'number', min: 1, max: 120, placeholder: '30', hint: 'duration;MiniMax 音乐 ≤190、ElevenLabs 音乐 3-600(转 ms)、Stability 按模型 190/380' },
94
+ duration: { label: '时长(秒)', type: 'number', min: 1, max: 200, placeholder: '30', hint: 'duration;MiniMax 音乐 ≤190、ElevenLabs 音乐 3-600(转 ms)、Stability 按模型 190/380' },
95
95
  format: { label: '输出格式', type: 'select', options: ['mp3', 'wav', 'pcm', 'flac', 'ogg'], hint: 'format / output_format / response_format' },
96
96
  lyrics: { label: '歌词(纯音乐模式可留空;多段用空行分隔)', type: 'text', placeholder: '第一段歌词…\n\n第二段歌词…', hint: 'MiniMax lyrics / ElevenLabs lyrics_text' },
97
97
  instrumental: { label: '纯音乐(无歌词/人声)', type: 'checkbox', hint: 'MiniMax is_instrumental / ElevenLabs force_instrumental' },
@@ -424,6 +424,8 @@ export function StudioView(props: {
424
424
  props.showToast('已保存到资源库')
425
425
  props.onLibraryChanged()
426
426
  }
427
+ // 引擎兜底提示(如未提供歌词时自动按纯音乐生成),最后展示避免被覆盖。
428
+ if (response.note !== undefined && response.note.trim() !== '') props.showToast(response.note)
427
429
  reload()
428
430
  return generated
429
431
  }
@@ -756,7 +758,13 @@ export function StudioView(props: {
756
758
  return (
757
759
  <label className={css.label} key={spec.key} title={spec.hint}>
758
760
  <span>{spec.label}</span>
759
- <textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
761
+ <textarea
762
+ className={css.textarea}
763
+ disabled={instrumental}
764
+ value={lyrics}
765
+ onChange={event => setLyrics(event.target.value)}
766
+ placeholder={instrumental ? '纯音乐模式无需填写歌词' : '第一段歌词…\n\n第二段歌词…'}
767
+ />
760
768
  </label>
761
769
  )
762
770
  case 'instrumental':
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.13'
11
+ export const PLUGIN_VERSION = '0.4.14'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
14
14
  export const SETTINGS_API = {
package/src/routes.ts CHANGED
@@ -515,7 +515,12 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
515
515
  // library-save is best-effort: generation and history already succeeded
516
516
  }
517
517
  }
518
- writeJson(res, 200, { ok: true, outputs: generated, history, ...(resources === undefined ? {} : { resources }) })
518
+ // ---- 音乐兜底提示:未提供歌词时引擎按纯音乐生成,告知前端一声 ----
519
+ const instrumentalFallback = request.mode === 'music'
520
+ && request.isInstrumental !== true
521
+ && (request.lyrics === undefined || request.lyrics.trim() === '')
522
+ const note = instrumentalFallback ? '未提供歌词,已按纯音乐生成' : undefined
523
+ writeJson(res, 200, { ok: true, outputs: generated, history, ...(resources === undefined ? {} : { resources }), ...(note === undefined ? {} : { note }) })
519
524
  } catch (error) {
520
525
  const code = error instanceof AudioGenError ? error.code : 'generate-failed'
521
526
  writeJson(res, 200, { ok: false, code, message: messageOf(error) })