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/client.js +345 -343
- package/lib/client.js.map +1 -1
- package/lib/index.js +126 -7
- package/package.json +1 -1
- package/skills/sfx/SKILL.md +6 -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/protocol.ts +1 -1
- package/src/routes.ts +6 -1
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
|
|
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:
|
|
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
|
-
...
|
|
385
|
-
...
|
|
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
|
-
|
|
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
|
-
...
|
|
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.
|
|
4
|
+
"version": "0.4.14",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/skills/sfx/SKILL.md
CHANGED
|
@@ -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)直连时不触发回退。音色设计在网关无对应兼容端点,会明确报错不便死等。
|
package/src/audio-engine.ts
CHANGED
|
@@ -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
|
|
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
|
-
...(
|
|
312
|
-
...(
|
|
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
|
-
|
|
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
|
-
...(
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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) })
|